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
+2 -2
View File
@@ -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",
},
{
@@ -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() {
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"}
{locale === "en-US"
? `${settlementSource} Settlement Ref`
: `${settlementSource} 结算参考`}
</span>
<span className="value highlight">
{current.wu_settlement != null
+4
View File
@@ -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;
+4 -4
View File
@@ -50,7 +50,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"future.closeDateAria": "关闭未来日期分析",
"future.currentObs": "当前实测",
"future.currentWeather": "当前天气",
"future.wuRef": "WU 结算参考",
"future.wuRef": "结算参考",
"future.sunrise": "日出时间",
"future.sunset": "日落时间",
"future.sunshine": "日照时长",
@@ -205,7 +205,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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",
+4
View File
@@ -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;
+1 -1
View File
@@ -70,7 +70,7 @@
<span class="value" id="heroCurrent"></span>
</div>
<div class="hero-item">
<span class="label">WU 结算参考</span>
<span class="label">结算参考</span>
<span class="value highlight" id="heroWU"></span>
</div>
<div class="hero-item">
+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
+59 -15
View File
@@ -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"),