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

This commit is contained in:
2569718930@qq.com
2026-03-17 13:21:14 +08:00
parent 609fc8914a
commit 54544e40b9
11 changed files with 574 additions and 54 deletions
+69 -11
View File
@@ -29,6 +29,19 @@ def _sf(value: Any) -> Optional[float]:
return None
def _resolve_settlement_source(city_meta: Dict[str, Any]) -> Tuple[str, str]:
source = str(city_meta.get("settlement_source") or "metar").strip().lower()
if not source:
source = "metar"
source_label_map = {
"metar": "METAR",
"hko": "HKO",
"cwa": "CWA",
"mgm": "MGM",
}
return source, source_label_map.get(source, source.upper())
def resolve_city_name(city_input: str) -> Tuple[Optional[str], List[str]]:
city_input_norm = city_input.strip().lower()
supported = list(CITY_REGISTRY.keys())
@@ -312,7 +325,15 @@ def build_city_query_report(
open_meteo = weather_data.get("open-meteo", {}) or {}
metar = weather_data.get("metar", {}) or {}
mgm = weather_data.get("mgm") or {}
settlement_current = weather_data.get("settlement_current") or {}
if not isinstance(settlement_current, dict):
settlement_current = {}
sc_current = settlement_current.get("current") or {}
if not isinstance(sc_current, dict):
sc_current = {}
city_meta = CITY_REGISTRY.get(city_name.lower(), {})
settlement_source, settlement_source_label = _resolve_settlement_source(city_meta)
use_settlement_current = settlement_source in {"hko", "cwa"} and bool(sc_current)
fallback_utc_offset = int(city_meta.get("tz_offset", 0))
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
if nws_periods:
@@ -333,6 +354,7 @@ def build_city_query_report(
risk_emoji = risk_profile.get("risk_level", "⚠️") if risk_profile else "⚠️"
msg_lines = [f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"]
msg_lines.append(f"🧾 结算源: <b>{settlement_source_label}</b>")
if risk_profile:
bias = risk_profile.get("bias", "±0.0")
msg_lines.append(
@@ -346,6 +368,7 @@ def build_city_query_report(
nws_high = _sf((weather_data.get("nws") or {}).get("today_high"))
mgm_high = _sf((mgm.get("today_high") if isinstance(mgm, dict) else None))
metar_max_so_far = _sf((metar.get("current") or {}).get("max_temp_so_far"))
settlement_max_so_far = _sf(sc_current.get("max_temp_so_far")) if use_settlement_current else None
today_t = _sf(max_temps[0]) if max_temps else None
fallback_source = None
@@ -356,7 +379,10 @@ def build_city_query_report(
today_t = candidate
fallback_source = source_name
break
if today_t is None and metar_max_so_far is not None:
if today_t is None and settlement_max_so_far is not None:
today_t = settlement_max_so_far
metar_only_fallback = True
elif today_t is None and metar_max_so_far is not None:
today_t = metar_max_so_far
metar_only_fallback = True
@@ -379,7 +405,10 @@ def build_city_query_report(
if metar_only_fallback:
if not sources:
sources = ["Model unavailable"]
comp_parts.append(f"METAR实测回退: {metar_max_so_far:.1f}{temp_symbol}")
source_name = settlement_source_label if use_settlement_current else "METAR"
fallback_val = settlement_max_so_far if settlement_max_so_far is not None else metar_max_so_far
if fallback_val is not None:
comp_parts.append(f"{source_name}实测回退: {fallback_val:.1f}{temp_symbol}")
if not sources:
sources = ["N/A"]
@@ -411,16 +440,38 @@ def build_city_query_report(
metar_current = metar.get("current", {}) if isinstance(metar, dict) else {}
mgm_current = mgm.get("current", {}) if isinstance(mgm, dict) else {}
cur_temp = _sf(metar_current.get("temp"))
primary_current = sc_current if use_settlement_current else metar_current
cur_temp = _sf(primary_current.get("temp"))
if cur_temp is None:
cur_temp = _sf(metar_current.get("temp"))
if cur_temp is None:
cur_temp = _sf(mgm_current.get("temp"))
max_p = _sf(metar_current.get("max_temp_so_far"))
max_p_time = metar_current.get("max_temp_time")
max_p = _sf(primary_current.get("max_temp_so_far"))
if max_p is None:
max_p = _sf(metar_current.get("max_temp_so_far"))
max_p_time = primary_current.get("max_temp_time")
if not max_p_time and not use_settlement_current:
max_p_time = metar_current.get("max_temp_time")
obs_t_str = "N/A"
metar_age_min = None
main_source = "METAR" if metar else "MGM"
main_source = settlement_source_label if use_settlement_current else ("METAR" if metar else "MGM")
if metar and metar.get("observation_time"):
settlement_obs_time = str(settlement_current.get("observation_time") or "").strip() if use_settlement_current else ""
if settlement_obs_time:
obs_t = settlement_obs_time
try:
dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
utc_offset = open_meteo.get("utc_offset")
if utc_offset is None:
utc_offset = fallback_utc_offset
local_dt = dt.astimezone(timezone(timedelta(seconds=int(utc_offset))))
obs_t_str = local_dt.strftime("%H:%M")
metar_age_min = int((datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60)
except Exception:
obs_t_str = obs_t[:16]
elif metar and metar.get("observation_time"):
obs_t = str(metar.get("observation_time"))
try:
if "T" in obs_t:
@@ -459,17 +510,24 @@ def build_city_query_report(
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f"WU {settled_val}{temp_symbol})"
max_str += f"{settlement_source_label} {settled_val}{temp_symbol})"
metar_clouds = metar_current.get("clouds", []) if isinstance(metar_current, dict) else []
metar_clouds = primary_current.get("clouds", []) if isinstance(primary_current, dict) else []
mgm_cloud = mgm_current.get("cloud_cover") if isinstance(mgm_current, dict) else None
wx_summary = _build_wx_summary(metar_current, metar_clouds, mgm_cloud)
wx_summary = _build_wx_summary(primary_current, metar_clouds, mgm_cloud)
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
)
if metar:
if use_settlement_current:
wind = primary_current.get("wind_speed_kt")
wind_dir = primary_current.get("wind_dir")
humidity = primary_current.get("humidity")
msg_lines.append(
f" [{settlement_source_label}] 🌪 {wind or 0}kt ({wind_dir or 0}°) | 💧 湿度: {humidity or 'N/A'}%"
)
elif metar:
wind = metar_current.get("wind_speed_kt")
wind_dir = metar_current.get("wind_dir")
vis = metar_current.get("visibility_mi")
+65 -18
View File
@@ -16,8 +16,16 @@ from src.analysis.deb_algorithm import (
_is_excluded_model_name,
)
from src.analysis.settlement_rounding import wu_round
from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
SETTLEMENT_SOURCE_LABELS = {
"metar": "METAR",
"hko": "HKO",
"cwa": "CWA",
"mgm": "MGM",
}
def _sf(v):
"""Safe float conversion — prevents JSON str types from breaking math."""
@@ -29,6 +37,17 @@ def _sf(v):
return None
def _resolve_settlement_source_label(city_name: Optional[str]) -> str:
if not city_name:
return "METAR"
city_key = str(city_name).strip().lower()
city_meta = CITY_REGISTRY.get(city_key, {})
source = str(city_meta.get("settlement_source") or "metar").strip().lower()
if not source:
source = "metar"
return SETTLEMENT_SOURCE_LABELS.get(source, source.upper())
def analyze_weather_trend(
weather_data: dict,
temp_symbol: str,
@@ -60,18 +79,38 @@ def analyze_weather_trend(
mu = None
sorted_probs = []
_deb_to_save = None
settlement_source_label = _resolve_settlement_source_label(city_name)
metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {})
mgm = weather_data.get("mgm") or {}
settlement_current = weather_data.get("settlement_current") or {}
if not isinstance(settlement_current, dict):
settlement_current = {}
settlement_now = settlement_current.get("current") or {}
if not isinstance(settlement_now, dict):
settlement_now = {}
nws = weather_data.get("nws", {})
empty_result = ("", "", {})
if not metar and not mgm:
if not metar and not mgm and not settlement_now:
return empty_result
max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far")) if metar else _sf(mgm.get("current", {}).get("mgm_max_temp"))
cur_temp = _sf(metar.get("current", {}).get("temp")) if metar else _sf(mgm.get("current", {}).get("temp"))
max_so_far = _sf(settlement_now.get("max_temp_so_far"))
if max_so_far is None:
max_so_far = (
_sf(metar.get("current", {}).get("max_temp_so_far"))
if metar
else _sf(mgm.get("current", {}).get("mgm_max_temp"))
)
cur_temp = _sf(settlement_now.get("temp"))
if cur_temp is None:
cur_temp = (
_sf(metar.get("current", {}).get("temp"))
if metar
else _sf(mgm.get("current", {}).get("temp"))
)
primary_current = settlement_now if settlement_now else (metar.get("current", {}) if metar else {})
daily = open_meteo.get("daily", {})
hourly = open_meteo.get("hourly", {})
@@ -100,7 +139,7 @@ def analyze_weather_trend(
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
)
wind_speed = metar.get("current", {}).get("wind_speed_kt", 0)
wind_speed = primary_current.get("wind_speed_kt", 0)
# === Local time/date (do not trust cached Open-Meteo local_time for date key) ===
utc_offset = _sf(open_meteo.get("utc_offset"))
@@ -140,12 +179,16 @@ def analyze_weather_trend(
local_hour = fallback_now.hour
local_minute = fallback_now.minute
# Use METAR observation date in city local time when available (reliable for actual_high date key).
metar_obs_time_raw = str(metar.get("observation_time") or "").strip()
if metar_obs_time_raw and utc_offset is not None:
# Use settlement/METAR observation date in city local time when available (reliable for actual_high date key).
obs_time_raw = str(settlement_current.get("observation_time") or "").strip()
if not obs_time_raw:
obs_time_raw = str(metar.get("observation_time") or "").strip()
if obs_time_raw and utc_offset is not None:
try:
metar_obs_dt = datetime.fromisoformat(metar_obs_time_raw.replace("Z", "+00:00"))
local_date_str = metar_obs_dt.astimezone(
obs_dt = datetime.fromisoformat(obs_time_raw.replace("Z", "+00:00"))
if obs_dt.tzinfo is None:
obs_dt = obs_dt.replace(tzinfo=timezone.utc)
local_date_str = obs_dt.astimezone(
timezone(timedelta(seconds=int(utc_offset)))
).strftime("%Y-%m-%d")
except Exception:
@@ -387,7 +430,10 @@ def analyze_weather_trend(
if is_dead_market:
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
dead_msg = (
f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} "
f"({settlement_source_label} 死盘确认)"
)
insights.append(dead_msg)
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
if max_so_far is not None:
@@ -474,13 +520,13 @@ def analyze_weather_trend(
if dist_to_boundary <= 0.3:
if fractional < 0.5:
msg = (
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}WU 结算 "
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}{settlement_source_label} 结算 "
f"<b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> "
f"就会进位到 {settled + 1}{temp_symbol}"
)
else:
msg = (
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}WU 结算 "
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}{settlement_source_label} 结算 "
f"<b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}"
)
@@ -538,30 +584,31 @@ def analyze_weather_trend(
ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}")
if max_so_far is not None:
ai_features.append(
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={wu_round(max_so_far)}{temp_symbol})。"
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} "
f"({settlement_source_label}结算={wu_round(max_so_far)}{temp_symbol})。"
)
if city_name:
_profile = get_city_risk_profile(city_name)
if _profile and _profile.get("metar_rounding"):
ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}")
if wind_speed:
wind_dir = metar.get("current", {}).get("wind_dir", "未知")
wind_dir = primary_current.get("wind_dir", "未知")
ai_features.append(f"🌬️ 当下风况: 约 {wind_speed}kt (方向 {wind_dir}°)。")
humidity = metar.get("current", {}).get("humidity")
humidity = primary_current.get("humidity")
if humidity and humidity > 80:
ai_features.append(f"💦 湿度极高 ({humidity}%)。")
clouds = metar.get("current", {}).get("clouds", [])
clouds = primary_current.get("clouds", [])
if clouds:
cover = clouds[-1].get("cover", "")
c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(cover, cover)
ai_features.append(f"☁️ 天空状况: {c_desc}")
wx_desc = metar.get("current", {}).get("wx_desc")
wx_desc = primary_current.get("wx_desc")
if wx_desc:
ai_features.append(f"🌧️ 天气现象: {wx_desc}")
max_temp_time_str = metar.get("current", {}).get("max_temp_time", "")
max_temp_time_str = primary_current.get("max_temp_time", "")
if max_so_far is not None and max_temp_time_str:
try:
max_h = int(max_temp_time_str.split(":")[0])