From 54544e40b97acbf04e23d842116fbc5316f1b4dc Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 17 Mar 2026 13:21:14 +0800 Subject: [PATCH] feat: Implement core data models, backend services for weather and market data, and initial dashboard components. --- frontend/components/dashboard/GuideModal.tsx | 4 +- .../components/dashboard/PanelSections.tsx | 5 +- frontend/lib/dashboard-types.ts | 4 + frontend/lib/i18n.ts | 8 +- frontend/lib/types.ts | 4 + frontend/public/legacy/index.html | 2 +- src/analysis/city_query_service.py | 80 ++++- src/analysis/trend_engine.py | 83 ++++- src/data_collection/city_registry.py | 54 +++ src/data_collection/weather_sources.py | 310 +++++++++++++++++- web/app.py | 74 ++++- 11 files changed, 574 insertions(+), 54 deletions(-) diff --git a/frontend/components/dashboard/GuideModal.tsx b/frontend/components/dashboard/GuideModal.tsx index 0a33cf30..1b9eebf7 100644 --- a/frontend/components/dashboard/GuideModal.tsx +++ b/frontend/components/dashboard/GuideModal.tsx @@ -14,7 +14,7 @@ const GUIDE_CARDS = { title: "结算概率引擎", }, { - body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。", + body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。", title: "结算点与主观测源", }, { @@ -40,7 +40,7 @@ const GUIDE_CARDS = { title: "Settlement Probability Engine", }, { - body: "Polymarket settlement follows airport METAR observations. The system prioritizes Aviation Weather API raw METAR and distinguishes observation time from receipt time to avoid misreading publication delay as temperature change.", + body: "Settlement source follows market rule by city: Milan (LIMC) and Warsaw (EPWA) settle on airport METAR, Hong Kong settles on HKO, and Taipei settles on CWA. METAR/MGM are still kept for intraday structure tracking with observation time vs receipt time separated.", title: "Settlement Source Logic", }, { diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 3db8c670..30dd94d0 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -158,6 +158,7 @@ export function HeroSummary() { const { weatherIcon, weatherText } = getWeatherSummary(data, locale); const metaItems = getHeroMetaItems(data, locale); const current = data.current || {}; + const settlementSource = String(current.settlement_source || "metar").toUpperCase(); const isMax = current.max_so_far != null && current.temp != null && @@ -196,7 +197,9 @@ export function HeroSummary() {
- {locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"} + {locale === "en-US" + ? `${settlementSource} Settlement Ref` + : `${settlementSource} 结算参考`} {current.wu_settlement != null diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index ebea4101..d6ed5b38 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -11,6 +11,8 @@ export interface CityListItem { icao: string; temp_unit: "celsius" | "fahrenheit"; is_major?: boolean; + settlement_source?: string; + settlement_source_label?: string; } export interface ProbabilityBucket { @@ -46,6 +48,8 @@ export interface CurrentConditions { max_so_far: number | null; max_temp_time: string | null; wu_settlement: number | null; + settlement_source?: string | null; + settlement_source_label?: string | null; obs_time: string | null; obs_age_min: number | null; wind_speed_kt: number | null; diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 60eec79a..bb83bdc3 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -50,7 +50,7 @@ const MESSAGES: Record> = { "guide.title": "📎 PolyWeather 系统技术说明", "guide.closeAria": "关闭技术说明", "guide.footer": - "数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。", + "数据源以 METAR、香港天文台(HKO)、中央气象署(CWA)、Turkish MGM、Open-Meteo、weather.gov 为主。", "history.title": "📊 历史准确率对账 - {city}", "history.closeAria": "关闭历史对账", @@ -68,7 +68,7 @@ const MESSAGES: Record> = { "future.closeDateAria": "关闭未来日期分析", "future.currentObs": "当前实测", "future.currentWeather": "当前天气", - "future.wuRef": "WU 结算参考", + "future.wuRef": "结算参考", "future.sunrise": "日出时间", "future.sunset": "日落时间", "future.sunshine": "日照时长", @@ -205,7 +205,7 @@ const MESSAGES: Record> = { "guide.title": "📎 PolyWeather Technical Overview", "guide.closeAria": "Close technical overview", "guide.footer": - "Primary data sources are Aviation Weather / METAR, Turkish MGM, Open-Meteo, and weather.gov.", + "Primary data sources are METAR, Hong Kong Observatory (HKO), CWA (Taiwan), Turkish MGM, Open-Meteo, and weather.gov.", "history.title": "📊 Historical Reconciliation - {city}", "history.closeAria": "Close history reconciliation", @@ -223,7 +223,7 @@ const MESSAGES: Record> = { "future.closeDateAria": "Close future-date analysis", "future.currentObs": "Current Observation", "future.currentWeather": "Current Weather", - "future.wuRef": "WU Settlement Ref", + "future.wuRef": "Settlement Ref", "future.sunrise": "Sunrise", "future.sunset": "Sunset", "future.sunshine": "Sunshine Duration", diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 88f9a34e..1cfefa13 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -168,6 +168,8 @@ export interface CityInfo { icao: string; temp_unit: "celsius" | "fahrenheit"; is_major: boolean; + settlement_source?: string; + settlement_source_label?: string; } export interface CitiesResponse { @@ -228,6 +230,8 @@ export interface CityAnalysis { max_so_far: number | null; max_temp_time: string; wu_settlement: number | null; + settlement_source?: string | null; + settlement_source_label?: string | null; obs_time: string; obs_age_min: number | null; wind_speed_kt: number | null; diff --git a/frontend/public/legacy/index.html b/frontend/public/legacy/index.html index 3549510b..683dee52 100644 --- a/frontend/public/legacy/index.html +++ b/frontend/public/legacy/index.html @@ -70,7 +70,7 @@
- WU 结算参考 + 结算参考
diff --git a/src/analysis/city_query_service.py b/src/analysis/city_query_service.py index 0dcc870e..4b5e21b8 100644 --- a/src/analysis/city_query_service.py +++ b/src/analysis/city_query_service.py @@ -29,6 +29,19 @@ def _sf(value: Any) -> Optional[float]: return None +def _resolve_settlement_source(city_meta: Dict[str, Any]) -> Tuple[str, str]: + source = str(city_meta.get("settlement_source") or "metar").strip().lower() + if not source: + source = "metar" + source_label_map = { + "metar": "METAR", + "hko": "HKO", + "cwa": "CWA", + "mgm": "MGM", + } + return source, source_label_map.get(source, source.upper()) + + def resolve_city_name(city_input: str) -> Tuple[Optional[str], List[str]]: city_input_norm = city_input.strip().lower() supported = list(CITY_REGISTRY.keys()) @@ -312,7 +325,15 @@ def build_city_query_report( open_meteo = weather_data.get("open-meteo", {}) or {} metar = weather_data.get("metar", {}) or {} mgm = weather_data.get("mgm") or {} + settlement_current = weather_data.get("settlement_current") or {} + if not isinstance(settlement_current, dict): + settlement_current = {} + sc_current = settlement_current.get("current") or {} + if not isinstance(sc_current, dict): + sc_current = {} city_meta = CITY_REGISTRY.get(city_name.lower(), {}) + settlement_source, settlement_source_label = _resolve_settlement_source(city_meta) + use_settlement_current = settlement_source in {"hko", "cwa"} and bool(sc_current) fallback_utc_offset = int(city_meta.get("tz_offset", 0)) nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or []) if nws_periods: @@ -333,6 +354,7 @@ def build_city_query_report( risk_emoji = risk_profile.get("risk_level", "⚠️") if risk_profile else "⚠️" msg_lines = [f"📍 {city_name.title()} ({time_str}) {risk_emoji}"] + msg_lines.append(f"🧾 结算源: {settlement_source_label}") if risk_profile: bias = risk_profile.get("bias", "±0.0") msg_lines.append( @@ -346,6 +368,7 @@ def build_city_query_report( nws_high = _sf((weather_data.get("nws") or {}).get("today_high")) mgm_high = _sf((mgm.get("today_high") if isinstance(mgm, dict) else None)) metar_max_so_far = _sf((metar.get("current") or {}).get("max_temp_so_far")) + settlement_max_so_far = _sf(sc_current.get("max_temp_so_far")) if use_settlement_current else None today_t = _sf(max_temps[0]) if max_temps else None fallback_source = None @@ -356,7 +379,10 @@ def build_city_query_report( today_t = candidate fallback_source = source_name break - if today_t is None and metar_max_so_far is not None: + if today_t is None and settlement_max_so_far is not None: + today_t = settlement_max_so_far + metar_only_fallback = True + elif today_t is None and metar_max_so_far is not None: today_t = metar_max_so_far metar_only_fallback = True @@ -379,7 +405,10 @@ def build_city_query_report( if metar_only_fallback: if not sources: sources = ["Model unavailable"] - comp_parts.append(f"METAR实测回退: {metar_max_so_far:.1f}{temp_symbol}") + source_name = settlement_source_label if use_settlement_current else "METAR" + fallback_val = settlement_max_so_far if settlement_max_so_far is not None else metar_max_so_far + if fallback_val is not None: + comp_parts.append(f"{source_name}实测回退: {fallback_val:.1f}{temp_symbol}") if not sources: sources = ["N/A"] @@ -411,16 +440,38 @@ def build_city_query_report( metar_current = metar.get("current", {}) if isinstance(metar, dict) else {} mgm_current = mgm.get("current", {}) if isinstance(mgm, dict) else {} - cur_temp = _sf(metar_current.get("temp")) + primary_current = sc_current if use_settlement_current else metar_current + cur_temp = _sf(primary_current.get("temp")) + if cur_temp is None: + cur_temp = _sf(metar_current.get("temp")) if cur_temp is None: cur_temp = _sf(mgm_current.get("temp")) - max_p = _sf(metar_current.get("max_temp_so_far")) - max_p_time = metar_current.get("max_temp_time") + max_p = _sf(primary_current.get("max_temp_so_far")) + if max_p is None: + max_p = _sf(metar_current.get("max_temp_so_far")) + max_p_time = primary_current.get("max_temp_time") + if not max_p_time and not use_settlement_current: + max_p_time = metar_current.get("max_temp_time") obs_t_str = "N/A" metar_age_min = None - main_source = "METAR" if metar else "MGM" + main_source = settlement_source_label if use_settlement_current else ("METAR" if metar else "MGM") - if metar and metar.get("observation_time"): + settlement_obs_time = str(settlement_current.get("observation_time") or "").strip() if use_settlement_current else "" + if settlement_obs_time: + obs_t = settlement_obs_time + try: + dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + utc_offset = open_meteo.get("utc_offset") + if utc_offset is None: + utc_offset = fallback_utc_offset + local_dt = dt.astimezone(timezone(timedelta(seconds=int(utc_offset)))) + obs_t_str = local_dt.strftime("%H:%M") + metar_age_min = int((datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60) + except Exception: + obs_t_str = obs_t[:16] + elif metar and metar.get("observation_time"): obs_t = str(metar.get("observation_time")) try: if "T" in obs_t: @@ -459,17 +510,24 @@ def build_city_query_report( max_str = f" (最高: {max_p}{temp_symbol}" if max_p_time: max_str += f" @{max_p_time}" - max_str += f" → WU {settled_val}{temp_symbol})" + max_str += f" → {settlement_source_label} {settled_val}{temp_symbol})" - metar_clouds = metar_current.get("clouds", []) if isinstance(metar_current, dict) else [] + metar_clouds = primary_current.get("clouds", []) if isinstance(primary_current, dict) else [] mgm_cloud = mgm_current.get("cloud_cover") if isinstance(mgm_current, dict) else None - wx_summary = _build_wx_summary(metar_current, metar_clouds, mgm_cloud) + wx_summary = _build_wx_summary(primary_current, metar_clouds, mgm_cloud) wx_display = f" {wx_summary}" if wx_summary else "" msg_lines.append( f"\n✈️ 实测 ({main_source}): {cur_temp}{temp_symbol}{max_str} |{wx_display} | {obs_t_str}{age_tag}" ) - if metar: + if use_settlement_current: + wind = primary_current.get("wind_speed_kt") + wind_dir = primary_current.get("wind_dir") + humidity = primary_current.get("humidity") + msg_lines.append( + f" [{settlement_source_label}] 🌪 {wind or 0}kt ({wind_dir or 0}°) | 💧 湿度: {humidity or 'N/A'}%" + ) + elif metar: wind = metar_current.get("wind_speed_kt") wind_dir = metar_current.get("wind_dir") vis = metar_current.get("visibility_mi") diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py index 13406842..ba1f610e 100644 --- a/src/analysis/trend_engine.py +++ b/src/analysis/trend_engine.py @@ -16,8 +16,16 @@ from src.analysis.deb_algorithm import ( _is_excluded_model_name, ) from src.analysis.settlement_rounding import wu_round +from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_risk_profiles import get_city_risk_profile +SETTLEMENT_SOURCE_LABELS = { + "metar": "METAR", + "hko": "HKO", + "cwa": "CWA", + "mgm": "MGM", +} + def _sf(v): """Safe float conversion — prevents JSON str types from breaking math.""" @@ -29,6 +37,17 @@ def _sf(v): return None +def _resolve_settlement_source_label(city_name: Optional[str]) -> str: + if not city_name: + return "METAR" + city_key = str(city_name).strip().lower() + city_meta = CITY_REGISTRY.get(city_key, {}) + source = str(city_meta.get("settlement_source") or "metar").strip().lower() + if not source: + source = "metar" + return SETTLEMENT_SOURCE_LABELS.get(source, source.upper()) + + def analyze_weather_trend( weather_data: dict, temp_symbol: str, @@ -60,18 +79,38 @@ def analyze_weather_trend( mu = None sorted_probs = [] _deb_to_save = None + settlement_source_label = _resolve_settlement_source_label(city_name) metar = weather_data.get("metar", {}) open_meteo = weather_data.get("open-meteo", {}) mgm = weather_data.get("mgm") or {} + settlement_current = weather_data.get("settlement_current") or {} + if not isinstance(settlement_current, dict): + settlement_current = {} + settlement_now = settlement_current.get("current") or {} + if not isinstance(settlement_now, dict): + settlement_now = {} nws = weather_data.get("nws", {}) empty_result = ("", "", {}) - if not metar and not mgm: + if not metar and not mgm and not settlement_now: return empty_result - max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far")) if metar else _sf(mgm.get("current", {}).get("mgm_max_temp")) - cur_temp = _sf(metar.get("current", {}).get("temp")) if metar else _sf(mgm.get("current", {}).get("temp")) + max_so_far = _sf(settlement_now.get("max_temp_so_far")) + if max_so_far is None: + max_so_far = ( + _sf(metar.get("current", {}).get("max_temp_so_far")) + if metar + else _sf(mgm.get("current", {}).get("mgm_max_temp")) + ) + cur_temp = _sf(settlement_now.get("temp")) + if cur_temp is None: + cur_temp = ( + _sf(metar.get("current", {}).get("temp")) + if metar + else _sf(mgm.get("current", {}).get("temp")) + ) + primary_current = settlement_now if settlement_now else (metar.get("current", {}) if metar else {}) daily = open_meteo.get("daily", {}) hourly = open_meteo.get("hourly", {}) @@ -100,7 +139,7 @@ def analyze_weather_trend( sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None ) - wind_speed = metar.get("current", {}).get("wind_speed_kt", 0) + wind_speed = primary_current.get("wind_speed_kt", 0) # === Local time/date (do not trust cached Open-Meteo local_time for date key) === utc_offset = _sf(open_meteo.get("utc_offset")) @@ -140,12 +179,16 @@ def analyze_weather_trend( local_hour = fallback_now.hour local_minute = fallback_now.minute - # Use METAR observation date in city local time when available (reliable for actual_high date key). - metar_obs_time_raw = str(metar.get("observation_time") or "").strip() - if metar_obs_time_raw and utc_offset is not None: + # Use settlement/METAR observation date in city local time when available (reliable for actual_high date key). + obs_time_raw = str(settlement_current.get("observation_time") or "").strip() + if not obs_time_raw: + obs_time_raw = str(metar.get("observation_time") or "").strip() + if obs_time_raw and utc_offset is not None: try: - metar_obs_dt = datetime.fromisoformat(metar_obs_time_raw.replace("Z", "+00:00")) - local_date_str = metar_obs_dt.astimezone( + obs_dt = datetime.fromisoformat(obs_time_raw.replace("Z", "+00:00")) + if obs_dt.tzinfo is None: + obs_dt = obs_dt.replace(tzinfo=timezone.utc) + local_date_str = obs_dt.astimezone( timezone(timedelta(seconds=int(utc_offset))) ).strftime("%Y-%m-%d") except Exception: @@ -387,7 +430,10 @@ def analyze_weather_trend( if is_dead_market: settled_wu = wu_round(max_so_far) if max_so_far is not None else 0 - dead_msg = f"🎲 结算预测:已锁定 {settled_wu}{temp_symbol} (死盘确认)" + dead_msg = ( + f"🎲 结算预测:已锁定 {settled_wu}{temp_symbol} " + f"({settlement_source_label} 死盘确认)" + ) insights.append(dead_msg) ai_features.append("🎲 状态: 确认死盘,结算已无悬念。") if max_so_far is not None: @@ -474,13 +520,13 @@ def analyze_weather_trend( if dist_to_boundary <= 0.3: if fractional < 0.5: msg = ( - f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 " + f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 " f"{settled}{temp_symbol},但只差 {0.5 - fractional:.1f}° " f"就会进位到 {settled + 1}{temp_symbol}!" ) else: msg = ( - f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 " + f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 " f"{settled}{temp_symbol},刚刚越过进位线,再降 " f"{fractional - 0.5:.1f}° 就会回落到 {settled - 1}{temp_symbol}。" ) @@ -538,30 +584,31 @@ def analyze_weather_trend( ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}。") if max_so_far is not None: ai_features.append( - f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={wu_round(max_so_far)}{temp_symbol})。" + f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} " + f"({settlement_source_label}结算={wu_round(max_so_far)}{temp_symbol})。" ) if city_name: _profile = get_city_risk_profile(city_name) if _profile and _profile.get("metar_rounding"): ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}") if wind_speed: - wind_dir = metar.get("current", {}).get("wind_dir", "未知") + wind_dir = primary_current.get("wind_dir", "未知") ai_features.append(f"🌬️ 当下风况: 约 {wind_speed}kt (方向 {wind_dir}°)。") - humidity = metar.get("current", {}).get("humidity") + humidity = primary_current.get("humidity") if humidity and humidity > 80: ai_features.append(f"💦 湿度极高 ({humidity}%)。") - clouds = metar.get("current", {}).get("clouds", []) + clouds = primary_current.get("clouds", []) if clouds: cover = clouds[-1].get("cover", "") c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(cover, cover) ai_features.append(f"☁️ 天空状况: {c_desc}。") - wx_desc = metar.get("current", {}).get("wx_desc") + wx_desc = primary_current.get("wx_desc") if wx_desc: ai_features.append(f"🌧️ 天气现象: {wx_desc}。") - max_temp_time_str = metar.get("current", {}).get("max_temp_time", "") + max_temp_time_str = primary_current.get("max_temp_time", "") if max_so_far is not None and max_temp_time_str: try: max_h = int(max_temp_time_str.split(":")[0]) diff --git a/src/data_collection/city_registry.py b/src/data_collection/city_registry.py index 7d908b00..f1072f85 100644 --- a/src/data_collection/city_registry.py +++ b/src/data_collection/city_registry.py @@ -63,6 +63,7 @@ CITY_REGISTRY = { "lat": 22.3080, "lon": 113.9185, "icao": "VHHH", + "settlement_source": "hko", "tz_offset": 28800, "use_fahrenheit": False, "is_major": True, @@ -72,6 +73,21 @@ CITY_REGISTRY = { "distance_km": 31.0, "warning": "海风与地形共同作用,午后对流触发后温度回落可能偏快。", }, + "taipei": { + "name": "Taipei", + "lat": 25.0694, + "lon": 121.5526, + "icao": "RCSS", + "settlement_source": "cwa", + "tz_offset": 28800, + "use_fahrenheit": False, + "is_major": True, + "risk_level": "low", + "risk_emoji": "🟢", + "airport_name": "台北松山机场", + "distance_km": 4.0, + "warning": "盆地地形叠加都市热岛,夏季午后对流前后温度波动较快。", + }, "shanghai": { "name": "Shanghai", "lat": 31.1434, @@ -296,6 +312,36 @@ CITY_REGISTRY = { "distance_km": 28.5, "warning": "距离非常远,空旷机场夜间降温快,冬季易生大雾压制气温。", }, + "milan": { + "name": "Milan", + "lat": 45.6306, + "lon": 8.7281, + "icao": "LIMC", + "settlement_source": "metar", + "tz_offset": 3600, + "use_fahrenheit": False, + "is_major": True, + "risk_level": "medium", + "risk_emoji": "🟡", + "airport_name": "马尔彭萨机场", + "distance_km": 49.0, + "warning": "机场距市区较远,午后峰值与夜间降温节奏需优先看机场站。", + }, + "warsaw": { + "name": "Warsaw", + "lat": 52.1657, + "lon": 20.9671, + "icao": "EPWA", + "settlement_source": "metar", + "tz_offset": 3600, + "use_fahrenheit": False, + "is_major": True, + "risk_level": "medium", + "risk_emoji": "🟡", + "airport_name": "肖邦机场", + "distance_km": 10.0, + "warning": "平原地形下辐射冷却显著,清晨与夜间温度回落可能偏快。", + }, } ALIASES = { @@ -305,11 +351,13 @@ ALIASES = { "dal": "dallas", "mia": "miami", "atl": "atlanta", "sea": "seattle", "tor": "toronto", "sel": "seoul", "seo": "seoul", "hkg": "hong kong", "hk": "hong kong", + "tpe": "taipei", "tp": "taipei", "taipei": "taipei", "sha": "shanghai", "sh": "shanghai", "sin": "singapore", "sg": "singapore", "tok": "tokyo", "tyo": "tokyo", "tlv": "tel aviv", "telaviv": "tel aviv", "ba": "buenos aires", "wel": "wellington", "luc": "lucknow", "sp": "sao paulo", "mun": "munich", + "mil": "milan", "mxp": "milan", "waw": "warsaw", "war": "warsaw", # Chinese names "安卡拉": "ankara", @@ -324,6 +372,8 @@ ALIASES = { "多伦多": "toronto", "首尔": "seoul", "香港": "hong kong", + "台北": "taipei", + "臺北": "taipei", "上海": "shanghai", "新加坡": "singapore", "东京": "tokyo", @@ -334,4 +384,8 @@ ALIASES = { "勒克瑙": "lucknow", "圣保罗": "sao paulo", "慕尼黑": "munich", + "米兰": "milan", + "米蘭": "milan", + "华沙": "warsaw", + "華沙": "warsaw", } diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index ef72b92a..c1c6d318 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -4,8 +4,8 @@ import requests import re import time import threading -from typing import Optional, Dict, List -from datetime import datetime, timedelta +from typing import Optional, Dict, List, Any +from datetime import datetime, timedelta, timezone from loguru import logger @@ -33,11 +33,14 @@ class WeatherDataCollector: "paris": ["LFPG", "LFPO", "LFPB"], "seoul": ["RKSI", "RKSS"], "hong kong": ["VHHH", "VMMC", "ZGSZ"], + "taipei": ["RCSS", "RCTP"], "shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"], "singapore": ["WSSS", "WSAP", "WMKK"], "tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"], "tel aviv": ["LLBG"], + "milan": ["LIMC", "LIML", "LIME", "LIPO"], "toronto": ["CYYZ", "CYTZ", "CYKF"], + "warsaw": ["EPWA", "EPMO", "EPLL"], "chicago": ["KORD", "KMDW", "KPWK", "KDPA"], "dallas": ["KDAL", "KDFW", "KADS", "KGKY"], "atlanta": ["KATL", "KPDK", "KFTY"], @@ -86,6 +89,16 @@ class WeatherDataCollector: ) self._metar_cache: Dict[str, Dict] = {} self._metar_cache_lock = threading.Lock() + self.settlement_cache_ttl_sec = int( + os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120") + ) + self._settlement_cache: Dict[str, Dict] = {} + self._settlement_cache_lock = threading.Lock() + self.cwa_open_data_auth = ( + os.getenv("CWA_OPEN_DATA_AUTH") + or os.getenv("CWA_OPEN_DATA_API_KEY") + or "rdec-key-123-45678-011121314" + ).strip() # 磁盘持久化缓存:重启后即可加载上次的预报数据,避免冷启动请求爆发 self._disk_cache_path = os.getenv( @@ -222,6 +235,280 @@ class WeatherDataCollector: now_ts = time.time() self._open_meteo_last_call_ts = now_ts + def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]: + now_ts = time.time() + with self._settlement_cache_lock: + cached = self._settlement_cache.get(key) + if cached and now_ts - float(cached.get("t", 0)) < self.settlement_cache_ttl_sec: + return cached.get("d") + return None + + def _set_settlement_cache(self, key: str, payload: Dict[str, Any]) -> None: + with self._settlement_cache_lock: + self._settlement_cache[key] = {"t": time.time(), "d": payload} + + @staticmethod + def _csv_rows(text: str) -> List[Dict[str, str]]: + normalized = str(text or "").lstrip("\ufeff") + if not normalized.strip(): + return [] + return [row for row in csv.DictReader(normalized.splitlines()) if isinstance(row, dict)] + + @staticmethod + def _hko_parse_local_iso(raw_yyyymmddhhmm: Optional[str]) -> Optional[str]: + raw = str(raw_yyyymmddhhmm or "").strip() + if len(raw) != 12 or not raw.isdigit(): + return None + try: + dt = datetime.strptime(raw, "%Y%m%d%H%M").replace( + tzinfo=timezone(timedelta(hours=8)) + ) + return dt.isoformat() + except Exception: + return None + + @staticmethod + def _hko_compass_to_deg(compass: Optional[str]) -> Optional[float]: + value = str(compass or "").strip().upper() + if not value: + return None + mapping = { + "N": 0.0, + "NNE": 22.5, + "NE": 45.0, + "ENE": 67.5, + "E": 90.0, + "ESE": 112.5, + "SE": 135.0, + "SSE": 157.5, + "S": 180.0, + "SSW": 202.5, + "SW": 225.0, + "WSW": 247.5, + "W": 270.0, + "WNW": 292.5, + "NW": 315.0, + "NNW": 337.5, + } + return mapping.get(value) + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + text = str(value).strip() + if not text or text in {"***", "N/A", "NA"}: + return None + try: + return float(text) + except Exception: + return None + + @staticmethod + def _pick_station_row(rows: List[Dict[str, str]], candidates: List[str]) -> Optional[Dict[str, str]]: + if not rows: + return None + normalized_map = { + str(name).strip().lower(): row + for row in rows + for name in [row.get("Automatic Weather Station")] + if isinstance(row, dict) and name + } + for name in candidates: + hit = normalized_map.get(str(name).strip().lower()) + if hit: + return hit + for row in rows: + station = str(row.get("Automatic Weather Station") or "").strip().lower() + if "observatory" in station: + return row + return rows[0] if rows else None + + def fetch_hko_settlement_current(self) -> Optional[Dict[str, Any]]: + """ + 香港市场结算源:香港天文台实时数据 + - 当前温度: latest_1min_temperature.csv (HK Observatory) + - 今日最高: latest_since_midnight_maxmin.csv (HK Observatory) + """ + cache_key = "hko:hong_kong" + cached = self._get_settlement_cache(cache_key) + if cached: + return cached + + try: + base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather" + temp_csv = self.session.get( + f"{base}/latest_1min_temperature.csv", + timeout=self.timeout, + ) + temp_csv.raise_for_status() + maxmin_csv = self.session.get( + f"{base}/latest_since_midnight_maxmin.csv", + timeout=self.timeout, + ) + maxmin_csv.raise_for_status() + humidity_csv = self.session.get( + f"{base}/latest_1min_humidity.csv", + timeout=self.timeout, + ) + humidity_csv.raise_for_status() + wind_csv = self.session.get( + f"{base}/latest_10min_wind.csv", + timeout=self.timeout, + ) + wind_csv.raise_for_status() + + temp_rows = self._csv_rows(temp_csv.text) + maxmin_rows = self._csv_rows(maxmin_csv.text) + humidity_rows = self._csv_rows(humidity_csv.text) + wind_rows = self._csv_rows(wind_csv.text) + + station_candidates = ["HK Observatory", "Hong Kong Observatory"] + temp_row = self._pick_station_row(temp_rows, station_candidates) + maxmin_row = self._pick_station_row(maxmin_rows, station_candidates) + humidity_row = self._pick_station_row(humidity_rows, station_candidates) + wind_row = self._pick_station_row(wind_rows, station_candidates) + if not temp_row or not maxmin_row: + return None + + obs_raw = temp_row.get("Date time") or maxmin_row.get("Date time") + obs_iso = self._hko_parse_local_iso(obs_raw) + + current_temp = self._safe_float( + temp_row.get("Air Temperature(degree Celsius)") + ) + max_so_far = self._safe_float( + maxmin_row.get("Maximum Air Temperature Since Midnight(degree Celsius)") + ) + min_so_far = self._safe_float( + maxmin_row.get("Minimum Air Temperature Since Midnight(degree Celsius)") + ) + humidity = ( + self._safe_float(humidity_row.get("Relative Humidity(percent)")) + if humidity_row + else None + ) + wind_speed_kmh = ( + self._safe_float(wind_row.get("10-Minute Mean Speed(km/hour)")) + if wind_row + else None + ) + wind_speed_kt = ( + round(float(wind_speed_kmh) / 1.852, 1) + if wind_speed_kmh is not None + else None + ) + wind_dir = self._hko_compass_to_deg( + wind_row.get("10-Minute Mean Wind Direction(Compass points)") + if wind_row + else None + ) + + payload: Dict[str, Any] = { + "source": "hko", + "source_label": "HKO", + "station_code": "HKO", + "station_name": "HK Observatory", + "observation_time": obs_iso, + "current": { + "temp": round(current_temp, 1) if current_temp is not None else None, + "max_temp_so_far": round(max_so_far, 1) if max_so_far is not None else None, + "max_temp_time": None, + "today_low": round(min_so_far, 1) if min_so_far is not None else None, + "humidity": round(humidity, 1) if humidity is not None else None, + "wind_speed_kt": wind_speed_kt, + "wind_dir": wind_dir, + }, + "unit": "celsius", + } + self._set_settlement_cache(cache_key, payload) + return payload + except Exception as exc: + logger.warning(f"HKO settlement fetch failed: {exc}") + return None + + def fetch_cwa_taipei_settlement_current(self) -> Optional[Dict[str, Any]]: + """ + 台北市场结算源:交通部中央气象署实时数据 (StationId=466920, 臺北站) + """ + cache_key = "cwa:taipei:466920" + cached = self._get_settlement_cache(cache_key) + if cached: + return cached + + try: + url = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/O-A0003-001" + response = self.session.get( + url, + params={ + "Authorization": self.cwa_open_data_auth, + "format": "JSON", + "StationId": "466920", + }, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() if response.content else {} + stations = (data.get("records") or {}).get("Station") or [] + if isinstance(stations, dict): + station = stations + elif isinstance(stations, list) and stations: + station = stations[0] + else: + station = None + if not isinstance(station, dict): + return None + + wx = station.get("WeatherElement") or {} + if not isinstance(wx, dict): + wx = {} + daily_extreme = wx.get("DailyExtreme") or {} + high_info = (((daily_extreme.get("DailyHigh") or {}).get("TemperatureInfo") or {})) + low_info = (((daily_extreme.get("DailyLow") or {}).get("TemperatureInfo") or {})) + high_time_raw = (((high_info.get("Occurred_at") or {}).get("DateTime"))) + high_hhmm = None + if high_time_raw and "T" in str(high_time_raw): + try: + high_hhmm = datetime.fromisoformat(str(high_time_raw)).strftime("%H:%M") + except Exception: + high_hhmm = str(high_time_raw).split("T")[1][:5] + + obs_time_raw = (station.get("ObsTime") or {}).get("DateTime") + + wind_speed_ms = self._safe_float(wx.get("WindSpeed")) + payload: Dict[str, Any] = { + "source": "cwa", + "source_label": "CWA", + "station_code": str(station.get("StationId") or "466920"), + "station_name": str(station.get("StationName") or "臺北"), + "observation_time": str(obs_time_raw or "").strip() or None, + "current": { + "temp": self._safe_float(wx.get("AirTemperature")), + "max_temp_so_far": self._safe_float(high_info.get("AirTemperature")), + "max_temp_time": high_hhmm, + "today_low": self._safe_float(low_info.get("AirTemperature")), + "humidity": self._safe_float(wx.get("RelativeHumidity")), + "wind_speed_kt": round(float(wind_speed_ms) * 1.943844, 1) + if wind_speed_ms is not None + else None, + "wind_dir": self._safe_float(wx.get("WindDirection")), + }, + "unit": "celsius", + } + self._set_settlement_cache(cache_key, payload) + return payload + except Exception as exc: + logger.warning(f"CWA settlement fetch failed: {exc}") + return None + + def fetch_settlement_current(self, city: str) -> Optional[Dict[str, Any]]: + normalized = str(city or "").strip().lower() + if normalized == "hong kong": + return self.fetch_hko_settlement_current() + if normalized == "taipei": + return self.fetch_cwa_taipei_settlement_current() + return None + def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]: """ Fetch current weather and forecast from OpenWeatherMap @@ -1664,6 +1951,9 @@ class WeatherDataCollector: "hong kong": "Hong Kong", "hong kong international airport": "Hong Kong", "香港": "Hong Kong", + "taipei": "Taipei", + "台北": "Taipei", + "臺北": "Taipei", "shanghai": "Shanghai", "上海": "Shanghai", "singapore": "Singapore", @@ -1671,6 +1961,9 @@ class WeatherDataCollector: "tokyo": "Tokyo", "东京": "Tokyo", "東京": "Tokyo", + "milan": "Milan", + "米兰": "Milan", + "米蘭": "Milan", "tel aviv": "Tel Aviv", "特拉维夫": "Tel Aviv", "toronto": "Toronto", @@ -1681,6 +1974,9 @@ class WeatherDataCollector: "惠灵顿": "Wellington", "buenos aires": "Buenos Aires", "布宜诺斯艾利斯": "Buenos Aires", + "warsaw": "Warsaw", + "华沙": "Warsaw", + "華沙": "Warsaw", } for key, val in known_cities.items(): @@ -1750,6 +2046,12 @@ class WeatherDataCollector: for key in list(self._metar_cache.keys()): if key.startswith(prefix): self._metar_cache.pop(key, None) + normalized = str(city or "").strip().lower() + with self._settlement_cache_lock: + if normalized == "hong kong": + self._settlement_cache.pop("hko:hong_kong", None) + elif normalized == "taipei": + self._settlement_cache.pop("cwa:taipei:466920", None) def fetch_all_sources( self, @@ -1813,6 +2115,10 @@ class WeatherDataCollector: else: logger.info(f"🌡️ {city} 使用摄氏度 (°C)") + settlement_current = self.fetch_settlement_current(city_lower) + if settlement_current: + results["settlement_current"] = settlement_current + if lat and lon: open_meteo = self.fetch_from_open_meteo( lat, lon, use_fahrenheit=use_fahrenheit diff --git a/web/app.py b/web/app.py index ef826feb..6fc7cec4 100644 --- a/web/app.py +++ b/web/app.py @@ -72,11 +72,19 @@ CITIES: Dict[str, Dict[str, Any]] = { "lat": info["lat"], "lon": info["lon"], "f": info["use_fahrenheit"], - "tz": info["tz_offset"] + "tz": info["tz_offset"], + "settlement_source": str(info.get("settlement_source") or "metar").strip().lower() or "metar", } for cid, info in CITY_REGISTRY.items() } +SETTLEMENT_SOURCE_LABELS: Dict[str, str] = { + "metar": "METAR", + "hko": "HKO", + "cwa": "CWA", + "mgm": "MGM", +} + # ────────────────────────────────────────────────────────── # Cache (5-min TTL) # ────────────────────────────────────────────────────────── @@ -322,6 +330,11 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: info = CITIES[city] lat, lon, is_f = info["lat"], info["lon"], info["f"] sym = "°F" if is_f else "°C" + settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" + settlement_source_label = SETTLEMENT_SOURCE_LABELS.get( + settlement_source, + settlement_source.upper(), + ) # ── 1. Fetch raw data ── raw = _weather.fetch_all_sources( @@ -333,6 +346,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: om = raw.get("open-meteo", {}) metar = raw.get("metar", {}) mgm = raw.get("mgm") or {} + settlement_current = raw.get("settlement_current") or {} ens_raw = raw.get("ensemble", {}) mm = raw.get("multi_model", {}) if not isinstance(om, dict): @@ -341,37 +355,54 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: metar = {} if not isinstance(mgm, dict): mgm = {} + if not isinstance(settlement_current, dict): + settlement_current = {} if not isinstance(ens_raw, dict): ens_raw = {} if not isinstance(mm, dict): mm = {} risk = CITY_RISK_PROFILES.get(city, {}) - # ── 2. Current conditions (METAR primary, MGM fallback) ── + # ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ── mc = metar.get("current", {}) if metar else {} mg_cur = mgm.get("current", {}) if mgm else {} + sc_cur = settlement_current.get("current", {}) if settlement_current else {} + use_settlement_current = settlement_source in {"hko", "cwa"} and bool(sc_cur) + primary_current = sc_cur if use_settlement_current else mc city_lower = city.lower() - cur_temp = _sf(mc.get("temp")) + cur_temp = _sf(primary_current.get("temp")) + if cur_temp is None: + cur_temp = _sf(mc.get("temp")) if cur_temp is None: cur_temp = _sf(mg_cur.get("temp")) - max_so_far = _sf(mc.get("max_temp_so_far")) + max_so_far = _sf(primary_current.get("max_temp_so_far")) + if max_so_far is None: + max_so_far = _sf(mc.get("max_temp_so_far")) if max_so_far is None: max_so_far = _sf(mg_cur.get("mgm_max_temp")) - max_temp_time = mc.get("max_temp_time") + max_temp_time = primary_current.get("max_temp_time") + if not max_temp_time and not use_settlement_current: + max_temp_time = mc.get("max_temp_time") if not max_temp_time: max_temp_time = mg_cur.get("time", "") if " " in max_temp_time: max_temp_time = max_temp_time.split(" ")[1][:5] + if max_temp_time == "": + max_temp_time = None wu_settle = wu_round(max_so_far) if max_so_far is not None else None # Observation time → local obs_time_str = "" metar_age_min = None - obs_t = metar.get("observation_time", "") if metar else "" + obs_t = "" + if use_settlement_current: + obs_t = str(settlement_current.get("observation_time") or "").strip() + if not obs_t: + obs_t = metar.get("observation_time", "") if metar else "" # 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置 utc_offset = om.get("utc_offset") if utc_offset is None: @@ -389,14 +420,16 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: utc_offset = info.get("tz", 0) if obs_t and "T" in obs_t: try: - dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00")) + dt = datetime.fromisoformat(str(obs_t).replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) obs_time_str = local_dt.strftime("%H:%M") metar_age_min = int( - (datetime.now(timezone.utc) - dt).total_seconds() / 60 + (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60 ) except Exception: - obs_time_str = obs_t[:16] + obs_time_str = str(obs_t)[:16] # ── 3. Local time parsing ── local_time_full = om.get("current", {}).get("local_time", "") @@ -831,21 +864,23 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "max_so_far": max_so_far, "max_temp_time": max_temp_time, "wu_settlement": wu_settle, + "settlement_source": settlement_source, + "settlement_source_label": settlement_source_label, "obs_time": obs_time_str, "obs_age_min": metar_age_min, "report_time": metar.get("report_time") if metar else None, "receipt_time": metar.get("receipt_time") if metar else None, "obs_time_epoch": metar.get("obs_time_epoch") if metar else None, - "wind_speed_kt": _sf(mc.get("wind_speed_kt")), - "wind_dir": _sf(mc.get("wind_dir")), - "humidity": _sf(mc.get("humidity")), + "wind_speed_kt": _sf(primary_current.get("wind_speed_kt")), + "wind_dir": _sf(primary_current.get("wind_dir")), + "humidity": _sf(primary_current.get("humidity")), "cloud_desc": cloud_desc, "clouds_raw": [ {"cover": c.get("cover"), "base": c.get("base")} for c in clouds ], - "visibility_mi": _sf(mc.get("visibility_mi")), - "wx_desc": mc.get("wx_desc"), - "raw_metar": mc.get("raw_metar"), + "visibility_mi": _sf(primary_current.get("visibility_mi")), + "wx_desc": primary_current.get("wx_desc"), + "raw_metar": primary_current.get("raw_metar"), }, "mgm": mgm_data, "mgm_nearby": raw.get("mgm_nearby", []), @@ -912,6 +947,11 @@ async def list_cities(request: Request): "icao": risk.get("icao", ""), "temp_unit": "fahrenheit" if info["f"] else "celsius", "is_major": CITY_REGISTRY.get(name, {}).get("is_major", True), + "settlement_source": info.get("settlement_source", "metar"), + "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get( + str(info.get("settlement_source") or "metar").strip().lower() or "metar", + str(info.get("settlement_source") or "metar").strip().upper() or "METAR", + ), } ) return {"cities": out} @@ -951,6 +991,8 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: "current": { "temp": data.get("current", {}).get("temp"), "obs_time": data.get("current", {}).get("obs_time"), + "settlement_source": data.get("current", {}).get("settlement_source"), + "settlement_source_label": data.get("current", {}).get("settlement_source_label"), }, "deb": { "prediction": data.get("deb", {}).get("prediction"), @@ -1071,6 +1113,8 @@ def _build_city_detail_payload( "local_date": data.get("local_date"), "temp_symbol": data.get("temp_symbol"), "current_temp": data.get("current", {}).get("temp"), + "settlement_source": data.get("current", {}).get("settlement_source"), + "settlement_source_label": data.get("current", {}).get("settlement_source_label"), "deb_prediction": data.get("deb", {}).get("prediction"), "risk_level": data.get("risk", {}).get("level"), "risk_warning": data.get("risk", {}).get("warning"),