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"),