diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 7b9b6d89..8089f080 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -285,6 +285,20 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { 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 { data: { @@ -315,6 +329,21 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "rgba(15, 23, 42, 0.96)", borderColor: "rgba(34, 211, 238, 0.2)", 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, @@ -504,81 +533,154 @@ export function FutureForecastModal() { }` : "--"; const upperAirSignal = detail.vertical_profile_signal || {}; + const tafSignal = detail.taf?.signal || {}; const topBucketProbability = normalizeMarketValue(topBucket?.probability); + const numericEdge = Number(marketScan?.edge_percent); const hottestMatchesSettlement = hottestBucketLabel !== "--" && settlementBucketLabel !== "--" && hottestBucketLabel === settlementBucketLabel; 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 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") { + 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 { - summary: locale === "en-US" - ? crowded - ? "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." - : "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." - : crowded - ? "高空结构仍偏支持冲高,但当前目标区间已经较拥挤。不宜过早做更低温区间,也不要盲目继续追价。" - : "高空结构仍偏支持冲高,市场最热桶还没完全挤到目标区间。不宜过早做更低温区间。", - note: locale === "en-US" - ? 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 - ? "暖侧仍有结构支撑,但价格已经不算便宜,继续加仓前先等近地面兑现。" - : "暖侧仍有结构支撑,且还没有完全过热,过早去做更低温区间风险较高。", + summary: + locale === "en-US" + ? "The combined upper-air, TAF, and market read still leans warmer. Do not fade lower buckets too early." + : "高空、TAF 和市场三层信号合并后仍偏暖侧,不宜过早做更低温区间。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ")}.` + : `${reasons.slice(0, 2).join(";")}。`, tone: "warm", value: locale === "en-US" ? "Lean warmer" : "偏暖侧", }; } - if (setup === "suppressed") { + if (score <= -1.5) { return { - summary: locale === "en-US" - ? crowded - ? "Upper-air structure leans toward capping the high, and the target bucket is already crowded. Chasing higher buckets needs extra caution." - : "Upper-air structure leans toward capping the high. Be more careful chasing higher buckets." - : crowded - ? "高空结构更偏向压住峰值,而且目标区间已经较拥挤。追更高温区间要更谨慎。" - : "高空结构更偏向压住峰值,追更高温区间要更谨慎。", - note: locale === "en-US" - ? crowded - ? "Both structure and crowding argue for caution on the hotter side." - : "The hotter side needs stronger surface confirmation before adding." - : crowded - ? "结构和拥挤度都不利于继续追热。" - : "更高温区间需要更强的近地面确认后再考虑追价。", + summary: + locale === "en-US" + ? "The combined upper-air, TAF, and market read leans more defensive. Be more careful chasing higher buckets." + : "高空、TAF 和市场三层信号合并后更偏防守,追更高温区间要更谨慎。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ")}.` + : `${reasons.slice(0, 2).join(";")}。`, tone: "cold", value: locale === "en-US" ? "Lean cautious" : "偏谨慎", }; } return { - summary: locale === "en-US" - ? crowded - ? "Upper-air structure is neutral, while the target bucket is already crowded. 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." - : crowded - ? "高空结构偏中性,但目标区间已经较拥挤。先看近地面结构和盘口变化,不急着继续站边。" - : "高空结构偏中性,先看近地面结构和盘口变化,不急着站边。", - note: locale === "en-US" - ? 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 - ? "单看高空层没有明确边,而且市场已经先站位,先等确认。" - : "单看高空层没有明确边,先等确认。", + summary: + locale === "en-US" + ? "The combined upper-air, TAF, and market read is mixed. Let surface structure and price action decide before taking a side." + : "高空、TAF 和市场三层信号目前偏混合,先看近地面结构和盘口变化,不急着站边。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.` + : `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`, tone: "", value: locale === "en-US" ? "Wait / confirm" : "先观察", }; }, [ + tafSignal.available, + tafSignal.disruption_level, + tafSignal.suppression_level, + marketScan?.signal_label, hottestMatchesSettlement, isToday, locale, + numericEdge, topBucketProbability, upperAirSignal.heating_setup, upperAirSignal.source, diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index b6bc9fdb..8016cb0d 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -315,7 +315,21 @@ export interface CityDetail { available?: boolean; source?: 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; ceiling_cover?: string | null; wind_regimes?: string[] | null; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 56a4099d..22c1d3b4 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -334,6 +334,32 @@ export function getTemperatureChartData( const min = Math.floor(Math.min(...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[] = []; if (detail.mgm?.temp != null) { @@ -385,6 +411,21 @@ export function getTemperatureChartData( : "台北按 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 { datasets: { @@ -395,6 +436,7 @@ export function getTemperatureChartData( mgmHourlyPoints, mgmPoints, offset, + tafMarkerPoints, temps, }, observationLabel: @@ -408,6 +450,7 @@ export function getTemperatureChartData( legendText: legendParts.join(" | "), max, min, + tafMarkers, times, }; } diff --git a/web/analysis_service.py b/web/analysis_service.py index 5256d65b..d58f9377 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -295,67 +295,238 @@ def _build_vertical_profile_signal( def _build_taf_signal( taf_data: Dict[str, Any], city: str, + local_date: str, + utc_offset: int, first_peak_h: int, last_peak_h: int, ) -> Dict[str, Any]: if str(city or "").strip().lower() == "hong kong": 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: return {} - precip_codes = re.findall( - r"\b(?:-|\+)?(?:TSRA|TS|VCTS|SHRA|RA|DZ|SN|SHSN|SHGS)\b", - raw_taf, - ) - cloud_matches = re.findall(r"\b(FEW|SCT|BKN|OVC)(\d{3})\b", raw_taf) - wind_matches = re.findall(r"\b(\d{3}|VRB)(\d{2,3})(?:G\d{2,3})?KT\b", raw_taf) - tempo_tokens = re.findall(r"\b(?:TEMPO|BECMG|PROB30|PROB40|FM\d{6})\b", raw_taf) + issue_raw = str((taf_data or {}).get("issue_time") or "").strip() + issue_dt = None + if issue_raw: + try: + issue_dt = datetime.fromisoformat(issue_raw.replace("Z", "+00:00")) + except Exception: + 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 ceiling_cover = None - 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 + wind_regimes: list[str] = [] + markers: list[Dict[str, Any]] = [] + active_segments: list[Dict[str, Any]] = [] - direction_buckets: list[str] = [] - for direction, _speed in wind_matches: - if direction == "VRB": - direction_buckets.append("variable") + def _segment_precip_level(tokens_block: list[str]) -> str: + joined = " ".join(tokens_block) + if re.search(r"\b(?:-|\+)?(?:TSRA|TS|VCTS|SHRA|SHSN|SHGS)\b", joined): + 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 - 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) - except Exception: - continue - if 135 <= deg <= 225: - direction_buckets.append("southerly") - elif deg >= 315 or deg <= 45: - direction_buckets.append("northerly") - else: - direction_buckets.append("cross") - unique_buckets = [bucket for bucket in dict.fromkeys(direction_buckets) if bucket] + if 135 <= deg <= 225: + segment_regimes.append("southerly") + elif deg >= 315 or deg <= 45: + segment_regimes.append("northerly") + else: + segment_regimes.append("cross") + for item in segment_regimes: + if item not in wind_regimes: + wind_regimes.append(item) - suppression_level = "low" - if any(code in {"TSRA", "TS", "VCTS", "SHRA", "SHSN", "SHGS"} for code in precip_codes): - suppression_level = "high" - elif precip_codes or (low_ceiling_ft is not None and low_ceiling_ft <= 4000): - suppression_level = "medium" + if segment["type"] in {"TEMPO", "BECMG", "PROB30", "PROB40", "PROB30 TEMPO", "PROB40 TEMPO"}: + disruption_level = "medium" if disruption_level == "low" else disruption_level + if segment["type"] in {"PROB30 TEMPO", "PROB40 TEMPO"} or level == "high": + disruption_level = "high" - disruption_level = "low" - if tempo_tokens and suppression_level == "high": - disruption_level = "high" - elif tempo_tokens or len(unique_buckets) >= 2: - disruption_level = "medium" + marker_time_local = start_local + marker_hour = marker_time_local.strftime("%H:00") + hazards = [] + if level != "low": + 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 - peak_window = f"{max(0, first_peak_h - 2):02d}:00-{min(23, last_peak_h + 1):02d}:00" + wind_shift = len(wind_regimes) >= 2 or "variable" in wind_regimes + peak_window = f"{peak_window_start.strftime('%H:%M')}-{peak_window_end.strftime('%H:%M')}" if suppression_level == "high": summary_zh = f"TAF 在峰值窗口({peak_window})提示阵雨或雷暴扰动,机场端压温风险偏高。" @@ -366,7 +537,6 @@ def _build_taf_signal( else: 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})." - if wind_shift: summary_zh += " 同时机场预报风向存在阶段性切换。" 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_to": (taf_data or {}).get("valid_time_to"), "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, "ceiling_cover": ceiling_cover, - "wind_regimes": unique_buckets, + "wind_regimes": wind_regimes, "wind_shift": wind_shift, "suppression_level": suppression_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 if isinstance(taf, dict) else {}, city, + local_date_str, + int(utc_offset or 0), first_peak_h, last_peak_h, )