feat: Implement core data models, backend services for weather and market data, and initial dashboard components.
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user