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