From b3651556223e4fb2957fab009b065b94261988ba Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 24 Mar 2026 02:58:29 +0800 Subject: [PATCH] Add TAF-based airport signals to intraday analysis --- frontend/lib/dashboard-types.ts | 22 +++++ frontend/lib/dashboard-utils.ts | 50 ++++++++++- src/data_collection/metar_sources.py | 59 +++++++++++++ src/data_collection/weather_sources.py | 17 ++++ web/analysis_service.py | 115 +++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index e2d13127..b6bc9fdb 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -304,6 +304,28 @@ export interface CityDetail { summary?: string | null; notes?: string[] | null; }; + taf?: { + source?: string | null; + icao?: string | null; + issue_time?: string | null; + valid_time_from?: string | null; + valid_time_to?: string | null; + raw_taf?: string | null; + signal?: { + available?: boolean; + source?: string | null; + peak_window?: string | null; + precip_codes?: string[] | null; + low_ceiling_ft?: number | null; + ceiling_cover?: string | null; + wind_regimes?: string[] | null; + wind_shift?: boolean | null; + suppression_level?: string | null; + disruption_level?: string | null; + summary_zh?: string | null; + summary_en?: string | null; + }; + }; vertical_profile_signal?: { source?: string | null; window_start?: string | null; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index cfd15632..56a4099d 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -584,6 +584,7 @@ export function computeFrontTrendSignal( locale: Locale = "zh-CN", ) { const upperAirSignal = detail.vertical_profile_signal || {}; + const tafSignal = detail.taf?.signal || {}; const upperAirTradeCue = upperAirSignal.source ? upperAirSignal.heating_setup === "supportive" ? { @@ -612,7 +613,7 @@ export function computeFrontTrendSignal( value: isEnglish(locale) ? "Wait / confirm" : "先观察", } : null; - const upperAirSummary = upperAirSignal.source + const baseUpperAirSummary = upperAirSignal.source ? (() => { const hasMetrics = upperAirSignal.cape_max != null || @@ -639,6 +640,43 @@ export function computeFrontTrendSignal( : "高空结构整体偏中性,单看这层不给明确边,交易仍要让近地面走势来定。"; })() : ""; + const tafSummary = + tafSignal.available && dateStr === detail.local_date + ? isEnglish(locale) + ? String(tafSignal.summary_en || "").trim() + : String(tafSignal.summary_zh || "").trim() + : ""; + const upperAirSummary = [baseUpperAirSummary, tafSummary] + .filter(Boolean) + .join(isEnglish(locale) ? " " : ""); + const tafMetric = + tafSignal.available && dateStr === detail.local_date + ? { + label: isEnglish(locale) ? "Airport TAF" : "机场预报", + note: tafSummary || + (isEnglish(locale) + ? "Airport TAF is available for the current peak window." + : "当前峰值窗口已接入机场 TAF 预报。"), + tone: + tafSignal.suppression_level === "high" + ? "cold" + : tafSignal.suppression_level === "low" + ? "warm" + : "", + value: + tafSignal.suppression_level === "high" + ? isEnglish(locale) + ? "Suppression watch" + : "防压温" + : tafSignal.suppression_level === "medium" + ? isEnglish(locale) + ? "Watch clouds/rain" + : "看云雨" + : isEnglish(locale) + ? "Mostly stable" + : "暂稳", + } + : null; const upperAirMetrics = upperAirSignal.source ? [ ...(upperAirTradeCue ? [upperAirTradeCue] : []), @@ -762,8 +800,11 @@ export function computeFrontTrendSignal( ? "Weak" : "弱", }, + ...(tafMetric ? [tafMetric] : []), ] - : []; + : tafMetric + ? [tafMetric] + : []; const rawBackendSummary = dateStr === detail.local_date ? String(detail.dynamic_commentary?.summary || "").trim() @@ -1140,6 +1181,9 @@ export function computeFrontTrendSignal( return parts.join(isEnglish(locale) ? " " : ""); })(); + const combinedSummary = [backendSummary || summary, tafSummary] + .filter(Boolean) + .join(isEnglish(locale) ? " " : ""); const cloudNote = (() => { if (cloudDelta >= 15 && tempDelta >= 0.8 && dewDelta >= 0.8) { return isEnglish(locale) @@ -1313,7 +1357,7 @@ export function computeFrontTrendSignal( upperAirSummary, precipMax, score, - summary: backendSummary || summary, + summary: combinedSummary || backendSummary || summary, weatherGovPeriods, }; } diff --git a/src/data_collection/metar_sources.py b/src/data_collection/metar_sources.py index 865f1750..fe24be34 100644 --- a/src/data_collection/metar_sources.py +++ b/src/data_collection/metar_sources.py @@ -224,6 +224,65 @@ class MetarSourceMixin: record_source_call("metar", "current", "parse_error", (time.perf_counter() - started) * 1000.0) return None + def fetch_taf(self, city: str, utc_offset: int = 0) -> Optional[Dict]: + """从 NOAA Aviation Weather Center 获取 TAF 机场终端区预报原文。""" + started = time.perf_counter() + icao = self.get_icao_code(city) + if not icao: + record_source_call("taf", "current", "missing_icao", (time.perf_counter() - started) * 1000.0) + return None + + cache_key = f"{icao}:{utc_offset}" + now_ts = time.time() + with self._taf_cache_lock: + cached = self._taf_cache.get(cache_key) + if cached and now_ts - cached["t"] < self.taf_cache_ttl_sec: + record_source_call("taf", "current", "cache_hit", (time.perf_counter() - started) * 1000.0) + return cached["d"] + + try: + url = "https://aviationweather.gov/api/data/taf" + params = { + "ids": icao, + "format": "json", + "hours": 24, + "_t": int(time.time()), + } + response = self.session.get(url, params=params, timeout=self.timeout) + response.raise_for_status() + data = response.json() + if not data: + return None + + latest = data[0] + result = { + "source": "taf", + "icao": icao, + "station_name": latest.get("name", icao), + "timestamp": datetime.utcnow().isoformat(), + "issue_time": latest.get("issueTime"), + "valid_time_from": latest.get("validTimeFrom"), + "valid_time_to": latest.get("validTimeTo"), + "raw_taf": latest.get("rawTAF") or latest.get("rawTaf") or latest.get("raw_text") or "", + } + with self._taf_cache_lock: + self._taf_cache[cache_key] = {"d": result, "t": now_ts} + record_source_call("taf", "current", "success", (time.perf_counter() - started) * 1000.0) + return result + except requests.exceptions.RequestException as exc: + logger.error(f"TAF 请求失败 ({icao}): {exc}") + with self._taf_cache_lock: + stale = self._taf_cache.get(cache_key) + if stale: + record_source_call("taf", "current", "stale_cache", (time.perf_counter() - started) * 1000.0) + return stale["d"] + record_source_call("taf", "current", "error", (time.perf_counter() - started) * 1000.0) + return None + except (KeyError, IndexError, TypeError, ValueError) as exc: + logger.error(f"TAF 数据解析失败 ({icao}): {exc}") + record_source_call("taf", "current", "parse_error", (time.perf_counter() - started) * 1000.0) + return None + def fetch_metar_nearby_cluster(self, icaos: List[str], use_fahrenheit: bool = False) -> list: """批量获取一组 ICAO 站点的 METAR 数据,用于地图周边显示。""" if not icaos: diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 8fe70c4d..3e12ed2f 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -132,6 +132,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._metar_cache: Dict[str, Dict] = {} self._metar_cache_lock = threading.Lock() + self.taf_cache_ttl_sec = int( + os.getenv("TAF_CACHE_TTL_SEC", "900") + ) + self._taf_cache: Dict[str, Dict] = {} + self._taf_cache_lock = threading.Lock() self.settlement_cache_ttl_sec = int( os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120") ) @@ -669,6 +674,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) if metar_data: results["metar"] = metar_data + if city_lower != "hong kong": + taf_data = self.fetch_taf(city, utc_offset=utc_offset) + if taf_data: + results["taf"] = taf_data self._attach_turkish_mgm_data(results, city_lower) if city_lower == "warsaw": @@ -690,6 +699,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) if metar_data: results["metar"] = metar_data + if city_lower != "hong kong": + taf_data = self.fetch_taf(city, utc_offset=fallback_utc_offset) + if taf_data: + results["taf"] = taf_data self._attach_turkish_mgm_data(results, city_lower) if city_lower == "warsaw": @@ -704,6 +717,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit) if metar_data: results["metar"] = metar_data + if city_lower != "hong kong": + taf_data = self.fetch_taf(city) + if taf_data: + results["taf"] = taf_data return results diff --git a/web/analysis_service.py b/web/analysis_service.py index cbb6c3b8..5256d65b 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import time as _time from datetime import datetime, timezone, timedelta from typing import Dict, Any, Optional @@ -291,6 +292,105 @@ def _build_vertical_profile_signal( } +def _build_taf_signal( + taf_data: Dict[str, Any], + city: str, + 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() + 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) + + 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 + + direction_buckets: list[str] = [] + for direction, _speed in wind_matches: + if direction == "VRB": + direction_buckets.append("variable") + continue + try: + 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] + + 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" + + disruption_level = "low" + if tempo_tokens and suppression_level == "high": + disruption_level = "high" + elif tempo_tokens or len(unique_buckets) >= 2: + disruption_level = "medium" + + 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" + + if suppression_level == "high": + summary_zh = f"TAF 在峰值窗口({peak_window})提示阵雨或雷暴扰动,机场端压温风险偏高。" + summary_en = f"TAF flags shower or thunderstorm disruption around the peak window ({peak_window}), so airport-side suppression risk is high." + elif suppression_level == "medium": + summary_zh = f"TAF 在峰值窗口({peak_window})提示云量或弱降水扰动,需要防峰值被压低。" + summary_en = f"TAF points to cloud or light-precip disruption around the peak window ({peak_window}); the airport high may be capped." + 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." + + return { + "available": True, + "source": "aviationweather-taf", + "raw_taf": raw_taf, + "issue_time": (taf_data or {}).get("issue_time"), + "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, + "low_ceiling_ft": low_ceiling_ft, + "ceiling_cover": ceiling_cover, + "wind_regimes": unique_buckets, + "wind_shift": wind_shift, + "suppression_level": suppression_level, + "disruption_level": disruption_level, + "summary_zh": summary_zh, + "summary_en": summary_en, + } + + def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: """Fetch, analyse, and return structured weather data for one city.""" # Check cache @@ -319,6 +419,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: ) om = raw.get("open-meteo", {}) metar = raw.get("metar", {}) + taf = raw.get("taf", {}) mgm = raw.get("mgm") or {} settlement_current = raw.get("settlement_current") or {} ens_raw = raw.get("ensemble", {}) @@ -771,6 +872,12 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: first_peak_h, last_peak_h, ) + taf_signal = _build_taf_signal( + taf if isinstance(taf, dict) else {}, + city, + first_peak_h, + last_peak_h, + ) # ── 13. Cloud description (METAR primary, MGM fallback) ── clouds = mc.get("clouds", []) @@ -995,6 +1102,12 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "hourly": today_hourly, "hourly_next_48h": next_48h_hourly, "vertical_profile_signal": vertical_profile_signal, + "taf": { + **(taf if isinstance(taf, dict) else {}), + "signal": taf_signal, + } + if taf_signal or taf + else {}, "metar_today_obs": metar_today_obs_payload, "metar_recent_obs": metar_recent_obs_payload, "settlement_today_obs": settlement_today_obs, @@ -1158,6 +1271,7 @@ def _build_city_detail_payload( "raw_metar": data.get("current", {}).get("raw_metar"), "current": data.get("current"), }, + "taf": data.get("taf") or {}, "weather_gov": {}, "mgm": data.get("mgm") or {}, "mgm_nearby": data.get("mgm_nearby") or [], @@ -1179,6 +1293,7 @@ def _build_city_detail_payload( "probabilities": data.get("probabilities") or {"mu": None, "distribution": []}, "dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []}, "vertical_profile_signal": data.get("vertical_profile_signal") or {}, + "taf": data.get("taf") or {}, "market_scan": market_scan, "risk": data.get("risk"), "nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"),