feat: Implement core data models, backend services for weather and market data, and initial dashboard components.

This commit is contained in:
2569718930@qq.com
2026-03-17 13:21:14 +08:00
parent 609fc8914a
commit 54544e40b9
11 changed files with 574 additions and 54 deletions
+69 -11
View File
@@ -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"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"]
msg_lines.append(f"🧾 结算源: <b>{settlement_source_label}</b>")
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✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{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")
+65 -18
View File
@@ -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"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
dead_msg = (
f"🎲 <b>结算预测</b>:已锁定 {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"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}WU 结算 "
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}{settlement_source_label} 结算 "
f"<b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> "
f"就会进位到 {settled + 1}{temp_symbol}"
)
else:
msg = (
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}WU 结算 "
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}{settlement_source_label} 结算 "
f"<b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {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])
+54
View File
@@ -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",
}
+308 -2
View File
@@ -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