Add TAF timing markers and market-aware intraday cues
This commit is contained in:
@@ -285,6 +285,20 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if ((todayChartData.tafMarkers || []).length > 0) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "#f59e0b",
|
||||||
|
borderColor: "#f59e0b",
|
||||||
|
borderWidth: 0,
|
||||||
|
data: todayChartData.datasets.tafMarkerPoints,
|
||||||
|
fill: false,
|
||||||
|
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
|
||||||
|
order: -2,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
pointRadius: 5,
|
||||||
|
showLine: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -315,6 +329,21 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
|||||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||||
borderColor: "rgba(34, 211, 238, 0.2)",
|
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const label = String(ctx.dataset.label || "");
|
||||||
|
if (label === "TAF Timing" || label === "TAF 时段") {
|
||||||
|
const marker = (todayChartData.tafMarkers || []).find(
|
||||||
|
(item) => item.index === ctx.dataIndex,
|
||||||
|
);
|
||||||
|
if (!marker) return label;
|
||||||
|
return `${label}: ${marker.summary}`;
|
||||||
|
}
|
||||||
|
const value = ctx.parsed.y;
|
||||||
|
if (value == null) return label;
|
||||||
|
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -504,81 +533,154 @@ export function FutureForecastModal() {
|
|||||||
}`
|
}`
|
||||||
: "--";
|
: "--";
|
||||||
const upperAirSignal = detail.vertical_profile_signal || {};
|
const upperAirSignal = detail.vertical_profile_signal || {};
|
||||||
|
const tafSignal = detail.taf?.signal || {};
|
||||||
const topBucketProbability = normalizeMarketValue(topBucket?.probability);
|
const topBucketProbability = normalizeMarketValue(topBucket?.probability);
|
||||||
|
const numericEdge = Number(marketScan?.edge_percent);
|
||||||
const hottestMatchesSettlement =
|
const hottestMatchesSettlement =
|
||||||
hottestBucketLabel !== "--" &&
|
hottestBucketLabel !== "--" &&
|
||||||
settlementBucketLabel !== "--" &&
|
settlementBucketLabel !== "--" &&
|
||||||
hottestBucketLabel === settlementBucketLabel;
|
hottestBucketLabel === settlementBucketLabel;
|
||||||
const marketAwareUpperAirCue = useMemo(() => {
|
const marketAwareUpperAirCue = useMemo(() => {
|
||||||
if (!isToday || !upperAirSignal.source) return null;
|
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
|
||||||
|
|
||||||
const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3;
|
const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3;
|
||||||
const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase();
|
const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase();
|
||||||
|
const tafSuppression = String(
|
||||||
|
tafSignal.suppression_level || "low",
|
||||||
|
).toLowerCase();
|
||||||
|
const tafDisruption = String(
|
||||||
|
tafSignal.disruption_level || "low",
|
||||||
|
).toLowerCase();
|
||||||
|
const signalLabel = String(marketScan?.signal_label || "").toUpperCase();
|
||||||
|
const edgeAbs = Number.isFinite(numericEdge) ? Math.abs(numericEdge) : 0;
|
||||||
|
const strongEdge = edgeAbs >= 8;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
if (setup === "supportive") {
|
if (setup === "supportive") {
|
||||||
|
score += 2;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "upper-air structure still supports daytime heating"
|
||||||
|
: "高空结构仍偏支持白天冲高",
|
||||||
|
);
|
||||||
|
} else if (setup === "suppressed") {
|
||||||
|
score -= 2;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "upper-air structure still leans toward capping the peak"
|
||||||
|
: "高空结构更偏向压住峰值",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tafSuppression === "high") {
|
||||||
|
score -= 2;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "TAF flags meaningful cloud/rain suppression near the peak window"
|
||||||
|
: "TAF 在峰值窗口提示云雨压温风险偏高",
|
||||||
|
);
|
||||||
|
} else if (tafSuppression === "medium") {
|
||||||
|
score -= 1;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "TAF keeps some cloud/rain suppression risk on the table"
|
||||||
|
: "TAF 仍提示一定的云雨压温风险",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tafDisruption === "high") {
|
||||||
|
score -= 1;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "TAF also suggests a noisier afternoon regime"
|
||||||
|
: "TAF 还提示午后扰动偏强",
|
||||||
|
);
|
||||||
|
} else if (tafDisruption === "medium") {
|
||||||
|
score -= 0.5;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "TAF keeps some afternoon timing noise in play"
|
||||||
|
: "TAF 提示午后仍可能有时段性扰动",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strongEdge && signalLabel === "BUY YES") {
|
||||||
|
score += 1;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? `market edge still leans hotter (${formatSignedPercent(numericEdge)})`
|
||||||
|
: `市场 edge 仍偏向更热一侧(${formatSignedPercent(numericEdge)})`,
|
||||||
|
);
|
||||||
|
} else if (strongEdge && signalLabel === "BUY NO") {
|
||||||
|
score -= 1;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? `market edge still leans cooler (${formatSignedPercent(numericEdge)})`
|
||||||
|
: `市场 edge 仍偏向更冷一侧(${formatSignedPercent(numericEdge)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crowded && score > 0) {
|
||||||
|
score -= 0.5;
|
||||||
|
reasons.push(
|
||||||
|
locale === "en-US"
|
||||||
|
? "the target bucket is already getting crowded"
|
||||||
|
: "目标区间已经开始变拥挤",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score >= 1.5) {
|
||||||
return {
|
return {
|
||||||
summary: locale === "en-US"
|
summary:
|
||||||
? crowded
|
locale === "en-US"
|
||||||
? "Upper-air structure still leans warmer, but the target bucket is already crowded. Do not fade lower buckets too early, and do not chase blindly either."
|
? "The combined upper-air, TAF, and market read still leans warmer. Do not fade lower buckets too early."
|
||||||
: "Upper-air structure still leans warmer, and the market is not fully crowded into the target bucket yet. Do not fade lower buckets too early."
|
: "高空、TAF 和市场三层信号合并后仍偏暖侧,不宜过早做更低温区间。",
|
||||||
: crowded
|
note:
|
||||||
? "高空结构仍偏支持冲高,但当前目标区间已经较拥挤。不宜过早做更低温区间,也不要盲目继续追价。"
|
locale === "en-US"
|
||||||
: "高空结构仍偏支持冲高,市场最热桶还没完全挤到目标区间。不宜过早做更低温区间。",
|
? `${reasons.slice(0, 2).join("; ")}.`
|
||||||
note: locale === "en-US"
|
: `${reasons.slice(0, 2).join(";")}。`,
|
||||||
? crowded
|
|
||||||
? "Warmer side still has structural support, but price is no longer cheap. Wait for surface follow-through before adding."
|
|
||||||
: "Warmer side still has structural support and is not fully overcrowded yet. Fading lower buckets too early is risky."
|
|
||||||
: crowded
|
|
||||||
? "暖侧仍有结构支撑,但价格已经不算便宜,继续加仓前先等近地面兑现。"
|
|
||||||
: "暖侧仍有结构支撑,且还没有完全过热,过早去做更低温区间风险较高。",
|
|
||||||
tone: "warm",
|
tone: "warm",
|
||||||
value: locale === "en-US" ? "Lean warmer" : "偏暖侧",
|
value: locale === "en-US" ? "Lean warmer" : "偏暖侧",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setup === "suppressed") {
|
if (score <= -1.5) {
|
||||||
return {
|
return {
|
||||||
summary: locale === "en-US"
|
summary:
|
||||||
? crowded
|
locale === "en-US"
|
||||||
? "Upper-air structure leans toward capping the high, and the target bucket is already crowded. Chasing higher buckets needs extra caution."
|
? "The combined upper-air, TAF, and market read leans more defensive. Be more careful chasing higher buckets."
|
||||||
: "Upper-air structure leans toward capping the high. Be more careful chasing higher buckets."
|
: "高空、TAF 和市场三层信号合并后更偏防守,追更高温区间要更谨慎。",
|
||||||
: crowded
|
note:
|
||||||
? "高空结构更偏向压住峰值,而且目标区间已经较拥挤。追更高温区间要更谨慎。"
|
locale === "en-US"
|
||||||
: "高空结构更偏向压住峰值,追更高温区间要更谨慎。",
|
? `${reasons.slice(0, 2).join("; ")}.`
|
||||||
note: locale === "en-US"
|
: `${reasons.slice(0, 2).join(";")}。`,
|
||||||
? crowded
|
|
||||||
? "Both structure and crowding argue for caution on the hotter side."
|
|
||||||
: "The hotter side needs stronger surface confirmation before adding."
|
|
||||||
: crowded
|
|
||||||
? "结构和拥挤度都不利于继续追热。"
|
|
||||||
: "更高温区间需要更强的近地面确认后再考虑追价。",
|
|
||||||
tone: "cold",
|
tone: "cold",
|
||||||
value: locale === "en-US" ? "Lean cautious" : "偏谨慎",
|
value: locale === "en-US" ? "Lean cautious" : "偏谨慎",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: locale === "en-US"
|
summary:
|
||||||
? crowded
|
locale === "en-US"
|
||||||
? "Upper-air structure is neutral, while the target bucket is already crowded. Let surface structure and price action decide before taking a side."
|
? "The combined upper-air, TAF, and market read is mixed. Let surface structure and price action decide before taking a side."
|
||||||
: "Upper-air structure is neutral. Let surface structure and price action decide before taking a side."
|
: "高空、TAF 和市场三层信号目前偏混合,先看近地面结构和盘口变化,不急着站边。",
|
||||||
: crowded
|
note:
|
||||||
? "高空结构偏中性,但目标区间已经较拥挤。先看近地面结构和盘口变化,不急着继续站边。"
|
locale === "en-US"
|
||||||
: "高空结构偏中性,先看近地面结构和盘口变化,不急着站边。",
|
? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.`
|
||||||
note: locale === "en-US"
|
: `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`,
|
||||||
? crowded
|
|
||||||
? "There is no clean edge from the upper-air layer alone, and the market is already leaning. Wait for confirmation."
|
|
||||||
: "There is no clean edge from the upper-air layer alone. Wait for confirmation."
|
|
||||||
: crowded
|
|
||||||
? "单看高空层没有明确边,而且市场已经先站位,先等确认。"
|
|
||||||
: "单看高空层没有明确边,先等确认。",
|
|
||||||
tone: "",
|
tone: "",
|
||||||
value: locale === "en-US" ? "Wait / confirm" : "先观察",
|
value: locale === "en-US" ? "Wait / confirm" : "先观察",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
tafSignal.available,
|
||||||
|
tafSignal.disruption_level,
|
||||||
|
tafSignal.suppression_level,
|
||||||
|
marketScan?.signal_label,
|
||||||
hottestMatchesSettlement,
|
hottestMatchesSettlement,
|
||||||
isToday,
|
isToday,
|
||||||
locale,
|
locale,
|
||||||
|
numericEdge,
|
||||||
topBucketProbability,
|
topBucketProbability,
|
||||||
upperAirSignal.heating_setup,
|
upperAirSignal.heating_setup,
|
||||||
upperAirSignal.source,
|
upperAirSignal.source,
|
||||||
|
|||||||
@@ -315,7 +315,21 @@ export interface CityDetail {
|
|||||||
available?: boolean;
|
available?: boolean;
|
||||||
source?: string | null;
|
source?: string | null;
|
||||||
peak_window?: string | null;
|
peak_window?: string | null;
|
||||||
precip_codes?: string[] | null;
|
segments?: Array<{
|
||||||
|
type?: string | null;
|
||||||
|
start_local?: string | null;
|
||||||
|
end_local?: string | null;
|
||||||
|
tokens?: string[] | null;
|
||||||
|
}> | null;
|
||||||
|
markers?: Array<{
|
||||||
|
label_time?: string | null;
|
||||||
|
marker_type?: string | null;
|
||||||
|
start_local?: string | null;
|
||||||
|
end_local?: string | null;
|
||||||
|
suppression_level?: string | null;
|
||||||
|
summary_zh?: string | null;
|
||||||
|
summary_en?: string | null;
|
||||||
|
}> | null;
|
||||||
low_ceiling_ft?: number | null;
|
low_ceiling_ft?: number | null;
|
||||||
ceiling_cover?: string | null;
|
ceiling_cover?: string | null;
|
||||||
wind_regimes?: string[] | null;
|
wind_regimes?: string[] | null;
|
||||||
|
|||||||
@@ -334,6 +334,32 @@ export function getTemperatureChartData(
|
|||||||
|
|
||||||
const min = Math.floor(Math.min(...allValues)) - 1;
|
const min = Math.floor(Math.min(...allValues)) - 1;
|
||||||
const max = Math.ceil(Math.max(...allValues)) + 1;
|
const max = Math.ceil(Math.max(...allValues)) + 1;
|
||||||
|
const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers)
|
||||||
|
? detail.taf?.signal?.markers || []
|
||||||
|
: [];
|
||||||
|
const tafMarkerValue = max - 0.4;
|
||||||
|
const tafMarkerPoints = new Array(times.length).fill(null);
|
||||||
|
const tafMarkers = tafMarkersRaw
|
||||||
|
.map((marker) => {
|
||||||
|
const labelTime = String(marker?.label_time || "").trim();
|
||||||
|
const index = times.indexOf(labelTime);
|
||||||
|
if (index >= 0) {
|
||||||
|
tafMarkerPoints[index] = tafMarkerValue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
endLocal: String(marker?.end_local || "").trim(),
|
||||||
|
index,
|
||||||
|
labelTime,
|
||||||
|
markerType: String(marker?.marker_type || "").trim(),
|
||||||
|
startLocal: String(marker?.start_local || "").trim(),
|
||||||
|
summary:
|
||||||
|
isEnglish(locale)
|
||||||
|
? String(marker?.summary_en || "").trim()
|
||||||
|
: String(marker?.summary_zh || "").trim(),
|
||||||
|
suppressionLevel: String(marker?.suppression_level || "").trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((marker) => marker.index >= 0);
|
||||||
|
|
||||||
const legendParts: string[] = [];
|
const legendParts: string[] = [];
|
||||||
if (detail.mgm?.temp != null) {
|
if (detail.mgm?.temp != null) {
|
||||||
@@ -385,6 +411,21 @@ export function getTemperatureChartData(
|
|||||||
: "台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作为结算参考线。",
|
: "台北按 NOAA RCTP 最终完成质控后的最高整度摄氏值结算;图中曲线仅作为结算参考线。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (tafMarkers.length) {
|
||||||
|
const tafText = tafMarkers
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((marker) =>
|
||||||
|
isEnglish(locale)
|
||||||
|
? `${marker.markerType} ${marker.startLocal}-${marker.endLocal}`
|
||||||
|
: `${marker.markerType} ${marker.startLocal}-${marker.endLocal}`,
|
||||||
|
)
|
||||||
|
.join(" | ");
|
||||||
|
legendParts.push(
|
||||||
|
isEnglish(locale)
|
||||||
|
? `TAF timing: ${tafText}`
|
||||||
|
: `TAF 时段: ${tafText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
datasets: {
|
datasets: {
|
||||||
@@ -395,6 +436,7 @@ export function getTemperatureChartData(
|
|||||||
mgmHourlyPoints,
|
mgmHourlyPoints,
|
||||||
mgmPoints,
|
mgmPoints,
|
||||||
offset,
|
offset,
|
||||||
|
tafMarkerPoints,
|
||||||
temps,
|
temps,
|
||||||
},
|
},
|
||||||
observationLabel:
|
observationLabel:
|
||||||
@@ -408,6 +450,7 @@ export function getTemperatureChartData(
|
|||||||
legendText: legendParts.join(" | "),
|
legendText: legendParts.join(" | "),
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
|
tafMarkers,
|
||||||
times,
|
times,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+228
-47
@@ -295,67 +295,238 @@ def _build_vertical_profile_signal(
|
|||||||
def _build_taf_signal(
|
def _build_taf_signal(
|
||||||
taf_data: Dict[str, Any],
|
taf_data: Dict[str, Any],
|
||||||
city: str,
|
city: str,
|
||||||
|
local_date: str,
|
||||||
|
utc_offset: int,
|
||||||
first_peak_h: int,
|
first_peak_h: int,
|
||||||
last_peak_h: int,
|
last_peak_h: int,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if str(city or "").strip().lower() == "hong kong":
|
if str(city or "").strip().lower() == "hong kong":
|
||||||
return {}
|
return {}
|
||||||
raw_taf = str((taf_data or {}).get("raw_taf") or "").upper().strip()
|
raw_taf = re.sub(r"\s+", " ", str((taf_data or {}).get("raw_taf") or "").upper().strip())
|
||||||
if not raw_taf:
|
if not raw_taf:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
precip_codes = re.findall(
|
issue_raw = str((taf_data or {}).get("issue_time") or "").strip()
|
||||||
r"\b(?:-|\+)?(?:TSRA|TS|VCTS|SHRA|RA|DZ|SN|SHSN|SHGS)\b",
|
issue_dt = None
|
||||||
raw_taf,
|
if issue_raw:
|
||||||
)
|
try:
|
||||||
cloud_matches = re.findall(r"\b(FEW|SCT|BKN|OVC)(\d{3})\b", raw_taf)
|
issue_dt = datetime.fromisoformat(issue_raw.replace("Z", "+00:00"))
|
||||||
wind_matches = re.findall(r"\b(\d{3}|VRB)(\d{2,3})(?:G\d{2,3})?KT\b", raw_taf)
|
except Exception:
|
||||||
tempo_tokens = re.findall(r"\b(?:TEMPO|BECMG|PROB30|PROB40|FM\d{6})\b", raw_taf)
|
issue_dt = None
|
||||||
|
if issue_dt is None:
|
||||||
|
issue_dt = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
local_tz = timezone(timedelta(seconds=int(utc_offset or 0)))
|
||||||
|
valid_match = re.search(r"\b(\d{2})(\d{2})/(\d{2})(\d{2})\b", raw_taf)
|
||||||
|
tokens = raw_taf.split()
|
||||||
|
if not valid_match:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _infer_utc(day: int, hour: int, minute: int = 0) -> datetime:
|
||||||
|
base = issue_dt
|
||||||
|
year = base.year
|
||||||
|
month = base.month
|
||||||
|
candidate = datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
if candidate < base - timedelta(days=20):
|
||||||
|
if month == 12:
|
||||||
|
candidate = datetime(year + 1, 1, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
candidate = datetime(year, month + 1, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
elif candidate > base + timedelta(days=20):
|
||||||
|
if month == 1:
|
||||||
|
candidate = datetime(year - 1, 12, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
candidate = datetime(year, month - 1, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _parse_period(token: str) -> tuple[Optional[datetime], Optional[datetime]]:
|
||||||
|
match = re.match(r"^(\d{2})(\d{2})/(\d{2})(\d{2})$", token)
|
||||||
|
if not match:
|
||||||
|
return None, None
|
||||||
|
start = _infer_utc(int(match.group(1)), int(match.group(2)))
|
||||||
|
end = _infer_utc(int(match.group(3)), int(match.group(4)))
|
||||||
|
if end <= start:
|
||||||
|
end += timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
valid_start_utc, valid_end_utc = _parse_period(valid_match.group(0))
|
||||||
|
if valid_start_utc is None or valid_end_utc is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
segment_indexes: list[int] = []
|
||||||
|
for idx, token in enumerate(tokens):
|
||||||
|
if re.match(r"^FM\d{6}$", token) or token in {"TEMPO", "BECMG", "PROB30", "PROB40"}:
|
||||||
|
segment_indexes.append(idx)
|
||||||
|
|
||||||
|
base_start_idx = 0
|
||||||
|
for idx, token in enumerate(tokens):
|
||||||
|
if token == valid_match.group(0):
|
||||||
|
base_start_idx = idx + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
segments: list[Dict[str, Any]] = []
|
||||||
|
first_segment_idx = segment_indexes[0] if segment_indexes else len(tokens)
|
||||||
|
if base_start_idx < first_segment_idx:
|
||||||
|
segments.append(
|
||||||
|
{
|
||||||
|
"type": "BASE",
|
||||||
|
"start_utc": valid_start_utc,
|
||||||
|
"end_utc": valid_end_utc,
|
||||||
|
"tokens": tokens[base_start_idx:first_segment_idx],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
idx_pos = 0
|
||||||
|
while idx_pos < len(segment_indexes):
|
||||||
|
start_idx = segment_indexes[idx_pos]
|
||||||
|
end_idx = segment_indexes[idx_pos + 1] if idx_pos + 1 < len(segment_indexes) else len(tokens)
|
||||||
|
token = tokens[start_idx]
|
||||||
|
seg_type = token
|
||||||
|
seg_start = valid_start_utc
|
||||||
|
seg_end = valid_end_utc
|
||||||
|
payload_start = start_idx + 1
|
||||||
|
|
||||||
|
if re.match(r"^FM(\d{2})(\d{2})(\d{2})$", token):
|
||||||
|
match = re.match(r"^FM(\d{2})(\d{2})(\d{2})$", token)
|
||||||
|
seg_type = "FM"
|
||||||
|
seg_start = _infer_utc(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||||
|
if idx_pos + 1 < len(segment_indexes):
|
||||||
|
next_token = tokens[segment_indexes[idx_pos + 1]]
|
||||||
|
next_match = re.match(r"^FM(\d{2})(\d{2})(\d{2})$", next_token)
|
||||||
|
if next_match:
|
||||||
|
seg_end = _infer_utc(int(next_match.group(1)), int(next_match.group(2)), int(next_match.group(3)))
|
||||||
|
else:
|
||||||
|
seg_end = valid_end_utc
|
||||||
|
else:
|
||||||
|
seg_end = valid_end_utc
|
||||||
|
elif token in {"TEMPO", "BECMG"}:
|
||||||
|
seg_type = token
|
||||||
|
if payload_start < len(tokens):
|
||||||
|
seg_start, seg_end = _parse_period(tokens[payload_start])
|
||||||
|
payload_start += 1
|
||||||
|
elif token in {"PROB30", "PROB40"}:
|
||||||
|
seg_type = token
|
||||||
|
if payload_start < len(tokens) and tokens[payload_start] == "TEMPO":
|
||||||
|
seg_type = f"{token} TEMPO"
|
||||||
|
payload_start += 1
|
||||||
|
if payload_start < len(tokens):
|
||||||
|
seg_start, seg_end = _parse_period(tokens[payload_start])
|
||||||
|
payload_start += 1
|
||||||
|
|
||||||
|
if seg_start is None or seg_end is None:
|
||||||
|
idx_pos += 1
|
||||||
|
continue
|
||||||
|
if seg_end <= seg_start:
|
||||||
|
seg_end = seg_start + timedelta(hours=1)
|
||||||
|
|
||||||
|
segments.append(
|
||||||
|
{
|
||||||
|
"type": seg_type,
|
||||||
|
"start_utc": seg_start,
|
||||||
|
"end_utc": seg_end,
|
||||||
|
"tokens": tokens[payload_start:end_idx],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
idx_pos += 1
|
||||||
|
|
||||||
|
peak_window_start = datetime.strptime(f"{local_date} {max(0, first_peak_h - 2):02d}:00", "%Y-%m-%d %H:%M").replace(tzinfo=local_tz)
|
||||||
|
peak_window_end = datetime.strptime(f"{local_date} {min(23, last_peak_h + 1):02d}:00", "%Y-%m-%d %H:%M").replace(tzinfo=local_tz)
|
||||||
|
|
||||||
|
precip_rank = {"low": 0, "medium": 1, "high": 2}
|
||||||
|
suppression_level = "low"
|
||||||
|
disruption_level = "low"
|
||||||
low_ceiling_ft = None
|
low_ceiling_ft = None
|
||||||
ceiling_cover = None
|
ceiling_cover = None
|
||||||
for cover, base in cloud_matches:
|
wind_regimes: list[str] = []
|
||||||
if cover not in {"BKN", "OVC"}:
|
markers: list[Dict[str, Any]] = []
|
||||||
continue
|
active_segments: list[Dict[str, Any]] = []
|
||||||
try:
|
|
||||||
base_ft = int(base) * 100
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if low_ceiling_ft is None or base_ft < low_ceiling_ft:
|
|
||||||
low_ceiling_ft = base_ft
|
|
||||||
ceiling_cover = cover
|
|
||||||
|
|
||||||
direction_buckets: list[str] = []
|
def _segment_precip_level(tokens_block: list[str]) -> str:
|
||||||
for direction, _speed in wind_matches:
|
joined = " ".join(tokens_block)
|
||||||
if direction == "VRB":
|
if re.search(r"\b(?:-|\+)?(?:TSRA|TS|VCTS|SHRA|SHSN|SHGS)\b", joined):
|
||||||
direction_buckets.append("variable")
|
return "high"
|
||||||
|
if re.search(r"\b(?:-|\+)?(?:RA|DZ|SN)\b", joined):
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
start_local = segment["start_utc"].astimezone(local_tz)
|
||||||
|
end_local = segment["end_utc"].astimezone(local_tz)
|
||||||
|
if end_local < peak_window_start or start_local > peak_window_end:
|
||||||
continue
|
continue
|
||||||
try:
|
active_segments.append(segment)
|
||||||
|
joined = " ".join(segment["tokens"])
|
||||||
|
level = _segment_precip_level(segment["tokens"])
|
||||||
|
if precip_rank[level] > precip_rank[suppression_level]:
|
||||||
|
suppression_level = level
|
||||||
|
|
||||||
|
cloud_matches = re.findall(r"\b(FEW|SCT|BKN|OVC)(\d{3})\b", joined)
|
||||||
|
for cover, base in cloud_matches:
|
||||||
|
if cover not in {"BKN", "OVC"}:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
base_ft = int(base) * 100
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if low_ceiling_ft is None or base_ft < low_ceiling_ft:
|
||||||
|
low_ceiling_ft = base_ft
|
||||||
|
ceiling_cover = cover
|
||||||
|
if low_ceiling_ft is not None and low_ceiling_ft <= 4000 and suppression_level == "low":
|
||||||
|
suppression_level = "medium"
|
||||||
|
|
||||||
|
wind_matches = re.findall(r"\b(\d{3}|VRB)(\d{2,3})(?:G\d{2,3})?KT\b", joined)
|
||||||
|
segment_regimes = []
|
||||||
|
for direction, _speed in wind_matches:
|
||||||
|
if direction == "VRB":
|
||||||
|
segment_regimes.append("variable")
|
||||||
|
continue
|
||||||
deg = int(direction)
|
deg = int(direction)
|
||||||
except Exception:
|
if 135 <= deg <= 225:
|
||||||
continue
|
segment_regimes.append("southerly")
|
||||||
if 135 <= deg <= 225:
|
elif deg >= 315 or deg <= 45:
|
||||||
direction_buckets.append("southerly")
|
segment_regimes.append("northerly")
|
||||||
elif deg >= 315 or deg <= 45:
|
else:
|
||||||
direction_buckets.append("northerly")
|
segment_regimes.append("cross")
|
||||||
else:
|
for item in segment_regimes:
|
||||||
direction_buckets.append("cross")
|
if item not in wind_regimes:
|
||||||
unique_buckets = [bucket for bucket in dict.fromkeys(direction_buckets) if bucket]
|
wind_regimes.append(item)
|
||||||
|
|
||||||
suppression_level = "low"
|
if segment["type"] in {"TEMPO", "BECMG", "PROB30", "PROB40", "PROB30 TEMPO", "PROB40 TEMPO"}:
|
||||||
if any(code in {"TSRA", "TS", "VCTS", "SHRA", "SHSN", "SHGS"} for code in precip_codes):
|
disruption_level = "medium" if disruption_level == "low" else disruption_level
|
||||||
suppression_level = "high"
|
if segment["type"] in {"PROB30 TEMPO", "PROB40 TEMPO"} or level == "high":
|
||||||
elif precip_codes or (low_ceiling_ft is not None and low_ceiling_ft <= 4000):
|
disruption_level = "high"
|
||||||
suppression_level = "medium"
|
|
||||||
|
|
||||||
disruption_level = "low"
|
marker_time_local = start_local
|
||||||
if tempo_tokens and suppression_level == "high":
|
marker_hour = marker_time_local.strftime("%H:00")
|
||||||
disruption_level = "high"
|
hazards = []
|
||||||
elif tempo_tokens or len(unique_buckets) >= 2:
|
if level != "low":
|
||||||
disruption_level = "medium"
|
hazards.append(level)
|
||||||
|
if low_ceiling_ft is not None and segment_regimes is not None:
|
||||||
|
hazards.append("cloud")
|
||||||
|
if segment_regimes:
|
||||||
|
hazards.append("wind")
|
||||||
|
summary_zh = (
|
||||||
|
f"{segment['type']} {start_local.strftime('%H:%M')}-{end_local.strftime('%H:%M')} "
|
||||||
|
f"{'有阵雨/雷暴扰动' if level == 'high' else '有云雨扰动' if level == 'medium' else '以稳定为主'}"
|
||||||
|
)
|
||||||
|
summary_en = (
|
||||||
|
f"{segment['type']} {start_local.strftime('%H:%M')}-{end_local.strftime('%H:%M')} "
|
||||||
|
f"{'shows shower/thunder disruption' if level == 'high' else 'shows cloud/rain disruption' if level == 'medium' else 'stays relatively stable'}"
|
||||||
|
)
|
||||||
|
markers.append(
|
||||||
|
{
|
||||||
|
"label_time": marker_hour,
|
||||||
|
"marker_type": segment["type"],
|
||||||
|
"start_local": start_local.strftime("%H:%M"),
|
||||||
|
"end_local": end_local.strftime("%H:%M"),
|
||||||
|
"suppression_level": level,
|
||||||
|
"summary_zh": summary_zh,
|
||||||
|
"summary_en": summary_en,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
wind_shift = len(unique_buckets) >= 2 or "variable" in unique_buckets
|
wind_shift = len(wind_regimes) >= 2 or "variable" in wind_regimes
|
||||||
peak_window = f"{max(0, first_peak_h - 2):02d}:00-{min(23, last_peak_h + 1):02d}:00"
|
peak_window = f"{peak_window_start.strftime('%H:%M')}-{peak_window_end.strftime('%H:%M')}"
|
||||||
|
|
||||||
if suppression_level == "high":
|
if suppression_level == "high":
|
||||||
summary_zh = f"TAF 在峰值窗口({peak_window})提示阵雨或雷暴扰动,机场端压温风险偏高。"
|
summary_zh = f"TAF 在峰值窗口({peak_window})提示阵雨或雷暴扰动,机场端压温风险偏高。"
|
||||||
@@ -366,7 +537,6 @@ def _build_taf_signal(
|
|||||||
else:
|
else:
|
||||||
summary_zh = f"TAF 在峰值窗口({peak_window})暂未提示明显云雨压温。"
|
summary_zh = f"TAF 在峰值窗口({peak_window})暂未提示明显云雨压温。"
|
||||||
summary_en = f"TAF does not flag a strong cloud/rain suppression signal around the peak window ({peak_window})."
|
summary_en = f"TAF does not flag a strong cloud/rain suppression signal around the peak window ({peak_window})."
|
||||||
|
|
||||||
if wind_shift:
|
if wind_shift:
|
||||||
summary_zh += " 同时机场预报风向存在阶段性切换。"
|
summary_zh += " 同时机场预报风向存在阶段性切换。"
|
||||||
summary_en += " Airport wind direction also shifts by regime during the window."
|
summary_en += " Airport wind direction also shifts by regime during the window."
|
||||||
@@ -379,10 +549,19 @@ def _build_taf_signal(
|
|||||||
"valid_time_from": (taf_data or {}).get("valid_time_from"),
|
"valid_time_from": (taf_data or {}).get("valid_time_from"),
|
||||||
"valid_time_to": (taf_data or {}).get("valid_time_to"),
|
"valid_time_to": (taf_data or {}).get("valid_time_to"),
|
||||||
"peak_window": peak_window,
|
"peak_window": peak_window,
|
||||||
"precip_codes": precip_codes,
|
"segments": [
|
||||||
|
{
|
||||||
|
"type": seg["type"],
|
||||||
|
"start_local": seg["start_utc"].astimezone(local_tz).strftime("%H:%M"),
|
||||||
|
"end_local": seg["end_utc"].astimezone(local_tz).strftime("%H:%M"),
|
||||||
|
"tokens": seg["tokens"],
|
||||||
|
}
|
||||||
|
for seg in active_segments
|
||||||
|
],
|
||||||
|
"markers": markers,
|
||||||
"low_ceiling_ft": low_ceiling_ft,
|
"low_ceiling_ft": low_ceiling_ft,
|
||||||
"ceiling_cover": ceiling_cover,
|
"ceiling_cover": ceiling_cover,
|
||||||
"wind_regimes": unique_buckets,
|
"wind_regimes": wind_regimes,
|
||||||
"wind_shift": wind_shift,
|
"wind_shift": wind_shift,
|
||||||
"suppression_level": suppression_level,
|
"suppression_level": suppression_level,
|
||||||
"disruption_level": disruption_level,
|
"disruption_level": disruption_level,
|
||||||
@@ -875,6 +1054,8 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
taf_signal = _build_taf_signal(
|
taf_signal = _build_taf_signal(
|
||||||
taf if isinstance(taf, dict) else {},
|
taf if isinstance(taf, dict) else {},
|
||||||
city,
|
city,
|
||||||
|
local_date_str,
|
||||||
|
int(utc_offset or 0),
|
||||||
first_peak_h,
|
first_peak_h,
|
||||||
last_peak_h,
|
last_peak_h,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user