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