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: "结算概率引擎",
|
title: "结算概率引擎",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
|
body: "结算源按城市市场定义:米兰(LIMC)、华沙(EPWA)使用机场 METAR;香港市场使用香港天文台(HKO);台北市场使用交通部中央气象署(CWA)。系统仍会保留 METAR/MGM 作为临近结构参考,并区分观测时间与接收时间。",
|
||||||
title: "结算点与主观测源",
|
title: "结算点与主观测源",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -40,7 +40,7 @@ const GUIDE_CARDS = {
|
|||||||
title: "Settlement Probability Engine",
|
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",
|
title: "Settlement Source Logic",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export function HeroSummary() {
|
|||||||
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
|
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
|
||||||
const metaItems = getHeroMetaItems(data, locale);
|
const metaItems = getHeroMetaItems(data, locale);
|
||||||
const current = data.current || {};
|
const current = data.current || {};
|
||||||
|
const settlementSource = String(current.settlement_source || "metar").toUpperCase();
|
||||||
const isMax =
|
const isMax =
|
||||||
current.max_so_far != null &&
|
current.max_so_far != null &&
|
||||||
current.temp != null &&
|
current.temp != null &&
|
||||||
@@ -196,7 +197,9 @@ export function HeroSummary() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="hero-item">
|
<div className="hero-item">
|
||||||
<span className="label">
|
<span className="label">
|
||||||
{locale === "en-US" ? "WU Settlement Ref" : "WU 结算参考"}
|
{locale === "en-US"
|
||||||
|
? `${settlementSource} Settlement Ref`
|
||||||
|
: `${settlementSource} 结算参考`}
|
||||||
</span>
|
</span>
|
||||||
<span className="value highlight">
|
<span className="value highlight">
|
||||||
{current.wu_settlement != null
|
{current.wu_settlement != null
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface CityListItem {
|
|||||||
icao: string;
|
icao: string;
|
||||||
temp_unit: "celsius" | "fahrenheit";
|
temp_unit: "celsius" | "fahrenheit";
|
||||||
is_major?: boolean;
|
is_major?: boolean;
|
||||||
|
settlement_source?: string;
|
||||||
|
settlement_source_label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbabilityBucket {
|
export interface ProbabilityBucket {
|
||||||
@@ -46,6 +48,8 @@ export interface CurrentConditions {
|
|||||||
max_so_far: number | null;
|
max_so_far: number | null;
|
||||||
max_temp_time: string | null;
|
max_temp_time: string | null;
|
||||||
wu_settlement: number | null;
|
wu_settlement: number | null;
|
||||||
|
settlement_source?: string | null;
|
||||||
|
settlement_source_label?: string | null;
|
||||||
obs_time: string | null;
|
obs_time: string | null;
|
||||||
obs_age_min: number | null;
|
obs_age_min: number | null;
|
||||||
wind_speed_kt: number | null;
|
wind_speed_kt: number | null;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"guide.title": "📎 PolyWeather 系统技术说明",
|
"guide.title": "📎 PolyWeather 系统技术说明",
|
||||||
"guide.closeAria": "关闭技术说明",
|
"guide.closeAria": "关闭技术说明",
|
||||||
"guide.footer":
|
"guide.footer":
|
||||||
"数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。",
|
"数据源以 METAR、香港天文台(HKO)、中央气象署(CWA)、Turkish MGM、Open-Meteo、weather.gov 为主。",
|
||||||
|
|
||||||
"history.title": "📊 历史准确率对账 - {city}",
|
"history.title": "📊 历史准确率对账 - {city}",
|
||||||
"history.closeAria": "关闭历史对账",
|
"history.closeAria": "关闭历史对账",
|
||||||
@@ -68,7 +68,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"future.closeDateAria": "关闭未来日期分析",
|
"future.closeDateAria": "关闭未来日期分析",
|
||||||
"future.currentObs": "当前实测",
|
"future.currentObs": "当前实测",
|
||||||
"future.currentWeather": "当前天气",
|
"future.currentWeather": "当前天气",
|
||||||
"future.wuRef": "WU 结算参考",
|
"future.wuRef": "结算参考",
|
||||||
"future.sunrise": "日出时间",
|
"future.sunrise": "日出时间",
|
||||||
"future.sunset": "日落时间",
|
"future.sunset": "日落时间",
|
||||||
"future.sunshine": "日照时长",
|
"future.sunshine": "日照时长",
|
||||||
@@ -205,7 +205,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"guide.title": "📎 PolyWeather Technical Overview",
|
"guide.title": "📎 PolyWeather Technical Overview",
|
||||||
"guide.closeAria": "Close technical overview",
|
"guide.closeAria": "Close technical overview",
|
||||||
"guide.footer":
|
"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.title": "📊 Historical Reconciliation - {city}",
|
||||||
"history.closeAria": "Close history reconciliation",
|
"history.closeAria": "Close history reconciliation",
|
||||||
@@ -223,7 +223,7 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
|
|||||||
"future.closeDateAria": "Close future-date analysis",
|
"future.closeDateAria": "Close future-date analysis",
|
||||||
"future.currentObs": "Current Observation",
|
"future.currentObs": "Current Observation",
|
||||||
"future.currentWeather": "Current Weather",
|
"future.currentWeather": "Current Weather",
|
||||||
"future.wuRef": "WU Settlement Ref",
|
"future.wuRef": "Settlement Ref",
|
||||||
"future.sunrise": "Sunrise",
|
"future.sunrise": "Sunrise",
|
||||||
"future.sunset": "Sunset",
|
"future.sunset": "Sunset",
|
||||||
"future.sunshine": "Sunshine Duration",
|
"future.sunshine": "Sunshine Duration",
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface CityInfo {
|
|||||||
icao: string;
|
icao: string;
|
||||||
temp_unit: "celsius" | "fahrenheit";
|
temp_unit: "celsius" | "fahrenheit";
|
||||||
is_major: boolean;
|
is_major: boolean;
|
||||||
|
settlement_source?: string;
|
||||||
|
settlement_source_label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CitiesResponse {
|
export interface CitiesResponse {
|
||||||
@@ -228,6 +230,8 @@ export interface CityAnalysis {
|
|||||||
max_so_far: number | null;
|
max_so_far: number | null;
|
||||||
max_temp_time: string;
|
max_temp_time: string;
|
||||||
wu_settlement: number | null;
|
wu_settlement: number | null;
|
||||||
|
settlement_source?: string | null;
|
||||||
|
settlement_source_label?: string | null;
|
||||||
obs_time: string;
|
obs_time: string;
|
||||||
obs_age_min: number | null;
|
obs_age_min: number | null;
|
||||||
wind_speed_kt: number | null;
|
wind_speed_kt: number | null;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<span class="value" id="heroCurrent">—</span>
|
<span class="value" id="heroCurrent">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-item">
|
<div class="hero-item">
|
||||||
<span class="label">WU 结算参考</span>
|
<span class="label">结算参考</span>
|
||||||
<span class="value highlight" id="heroWU">—</span>
|
<span class="value highlight" id="heroWU">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-item">
|
<div class="hero-item">
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ def _sf(value: Any) -> Optional[float]:
|
|||||||
return None
|
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]]:
|
def resolve_city_name(city_input: str) -> Tuple[Optional[str], List[str]]:
|
||||||
city_input_norm = city_input.strip().lower()
|
city_input_norm = city_input.strip().lower()
|
||||||
supported = list(CITY_REGISTRY.keys())
|
supported = list(CITY_REGISTRY.keys())
|
||||||
@@ -312,7 +325,15 @@ def build_city_query_report(
|
|||||||
open_meteo = weather_data.get("open-meteo", {}) or {}
|
open_meteo = weather_data.get("open-meteo", {}) or {}
|
||||||
metar = weather_data.get("metar", {}) or {}
|
metar = weather_data.get("metar", {}) or {}
|
||||||
mgm = weather_data.get("mgm") 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(), {})
|
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))
|
fallback_utc_offset = int(city_meta.get("tz_offset", 0))
|
||||||
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
|
nws_periods = ((weather_data.get("nws") or {}).get("forecast_periods") or [])
|
||||||
if nws_periods:
|
if nws_periods:
|
||||||
@@ -333,6 +354,7 @@ def build_city_query_report(
|
|||||||
risk_emoji = risk_profile.get("risk_level", "⚠️") if risk_profile else "⚠️"
|
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 = [f"📍 <b>{city_name.title()}</b> ({time_str}) {risk_emoji}"]
|
||||||
|
msg_lines.append(f"🧾 结算源: <b>{settlement_source_label}</b>")
|
||||||
if risk_profile:
|
if risk_profile:
|
||||||
bias = risk_profile.get("bias", "±0.0")
|
bias = risk_profile.get("bias", "±0.0")
|
||||||
msg_lines.append(
|
msg_lines.append(
|
||||||
@@ -346,6 +368,7 @@ def build_city_query_report(
|
|||||||
nws_high = _sf((weather_data.get("nws") or {}).get("today_high"))
|
nws_high = _sf((weather_data.get("nws") or {}).get("today_high"))
|
||||||
mgm_high = _sf((mgm.get("today_high") if isinstance(mgm, dict) else None))
|
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"))
|
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
|
today_t = _sf(max_temps[0]) if max_temps else None
|
||||||
fallback_source = None
|
fallback_source = None
|
||||||
@@ -356,7 +379,10 @@ def build_city_query_report(
|
|||||||
today_t = candidate
|
today_t = candidate
|
||||||
fallback_source = source_name
|
fallback_source = source_name
|
||||||
break
|
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
|
today_t = metar_max_so_far
|
||||||
metar_only_fallback = True
|
metar_only_fallback = True
|
||||||
|
|
||||||
@@ -379,7 +405,10 @@ def build_city_query_report(
|
|||||||
if metar_only_fallback:
|
if metar_only_fallback:
|
||||||
if not sources:
|
if not sources:
|
||||||
sources = ["Model unavailable"]
|
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:
|
if not sources:
|
||||||
sources = ["N/A"]
|
sources = ["N/A"]
|
||||||
|
|
||||||
@@ -411,16 +440,38 @@ def build_city_query_report(
|
|||||||
|
|
||||||
metar_current = metar.get("current", {}) if isinstance(metar, dict) else {}
|
metar_current = metar.get("current", {}) if isinstance(metar, dict) else {}
|
||||||
mgm_current = mgm.get("current", {}) if isinstance(mgm, 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:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mgm_current.get("temp"))
|
cur_temp = _sf(mgm_current.get("temp"))
|
||||||
max_p = _sf(metar_current.get("max_temp_so_far"))
|
max_p = _sf(primary_current.get("max_temp_so_far"))
|
||||||
max_p_time = metar_current.get("max_temp_time")
|
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"
|
obs_t_str = "N/A"
|
||||||
metar_age_min = None
|
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"))
|
obs_t = str(metar.get("observation_time"))
|
||||||
try:
|
try:
|
||||||
if "T" in obs_t:
|
if "T" in obs_t:
|
||||||
@@ -459,17 +510,24 @@ def build_city_query_report(
|
|||||||
max_str = f" (最高: {max_p}{temp_symbol}"
|
max_str = f" (最高: {max_p}{temp_symbol}"
|
||||||
if max_p_time:
|
if max_p_time:
|
||||||
max_str += f" @{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
|
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 ""
|
wx_display = f" {wx_summary}" if wx_summary else ""
|
||||||
msg_lines.append(
|
msg_lines.append(
|
||||||
f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}{age_tag}"
|
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 = metar_current.get("wind_speed_kt")
|
||||||
wind_dir = metar_current.get("wind_dir")
|
wind_dir = metar_current.get("wind_dir")
|
||||||
vis = metar_current.get("visibility_mi")
|
vis = metar_current.get("visibility_mi")
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ from src.analysis.deb_algorithm import (
|
|||||||
_is_excluded_model_name,
|
_is_excluded_model_name,
|
||||||
)
|
)
|
||||||
from src.analysis.settlement_rounding import wu_round
|
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
|
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):
|
def _sf(v):
|
||||||
"""Safe float conversion — prevents JSON str types from breaking math."""
|
"""Safe float conversion — prevents JSON str types from breaking math."""
|
||||||
@@ -29,6 +37,17 @@ def _sf(v):
|
|||||||
return None
|
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(
|
def analyze_weather_trend(
|
||||||
weather_data: dict,
|
weather_data: dict,
|
||||||
temp_symbol: str,
|
temp_symbol: str,
|
||||||
@@ -60,18 +79,38 @@ def analyze_weather_trend(
|
|||||||
mu = None
|
mu = None
|
||||||
sorted_probs = []
|
sorted_probs = []
|
||||||
_deb_to_save = None
|
_deb_to_save = None
|
||||||
|
settlement_source_label = _resolve_settlement_source_label(city_name)
|
||||||
|
|
||||||
metar = weather_data.get("metar", {})
|
metar = weather_data.get("metar", {})
|
||||||
open_meteo = weather_data.get("open-meteo", {})
|
open_meteo = weather_data.get("open-meteo", {})
|
||||||
mgm = weather_data.get("mgm") or {}
|
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", {})
|
nws = weather_data.get("nws", {})
|
||||||
|
|
||||||
empty_result = ("", "", {})
|
empty_result = ("", "", {})
|
||||||
if not metar and not mgm:
|
if not metar and not mgm and not settlement_now:
|
||||||
return empty_result
|
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"))
|
max_so_far = _sf(settlement_now.get("max_temp_so_far"))
|
||||||
cur_temp = _sf(metar.get("current", {}).get("temp")) if metar else _sf(mgm.get("current", {}).get("temp"))
|
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", {})
|
daily = open_meteo.get("daily", {})
|
||||||
hourly = open_meteo.get("hourly", {})
|
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
|
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) ===
|
# === Local time/date (do not trust cached Open-Meteo local_time for date key) ===
|
||||||
utc_offset = _sf(open_meteo.get("utc_offset"))
|
utc_offset = _sf(open_meteo.get("utc_offset"))
|
||||||
@@ -140,12 +179,16 @@ def analyze_weather_trend(
|
|||||||
local_hour = fallback_now.hour
|
local_hour = fallback_now.hour
|
||||||
local_minute = fallback_now.minute
|
local_minute = fallback_now.minute
|
||||||
|
|
||||||
# Use METAR observation date in city local time when available (reliable for actual_high date key).
|
# Use settlement/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()
|
obs_time_raw = str(settlement_current.get("observation_time") or "").strip()
|
||||||
if metar_obs_time_raw and utc_offset is not None:
|
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:
|
try:
|
||||||
metar_obs_dt = datetime.fromisoformat(metar_obs_time_raw.replace("Z", "+00:00"))
|
obs_dt = datetime.fromisoformat(obs_time_raw.replace("Z", "+00:00"))
|
||||||
local_date_str = metar_obs_dt.astimezone(
|
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)))
|
timezone(timedelta(seconds=int(utc_offset)))
|
||||||
).strftime("%Y-%m-%d")
|
).strftime("%Y-%m-%d")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -387,7 +430,10 @@ def analyze_weather_trend(
|
|||||||
|
|
||||||
if is_dead_market:
|
if is_dead_market:
|
||||||
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
|
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)
|
insights.append(dead_msg)
|
||||||
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
|
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
|
||||||
if max_so_far is not None:
|
if max_so_far is not None:
|
||||||
@@ -474,13 +520,13 @@ def analyze_weather_trend(
|
|||||||
if dist_to_boundary <= 0.3:
|
if dist_to_boundary <= 0.3:
|
||||||
if fractional < 0.5:
|
if fractional < 0.5:
|
||||||
msg = (
|
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"<b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> "
|
||||||
f"就会进位到 {settled + 1}{temp_symbol}!"
|
f"就会进位到 {settled + 1}{temp_symbol}!"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = (
|
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>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
|
||||||
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}。"
|
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}。")
|
ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}。")
|
||||||
if max_so_far is not None:
|
if max_so_far is not None:
|
||||||
ai_features.append(
|
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:
|
if city_name:
|
||||||
_profile = get_city_risk_profile(city_name)
|
_profile = get_city_risk_profile(city_name)
|
||||||
if _profile and _profile.get("metar_rounding"):
|
if _profile and _profile.get("metar_rounding"):
|
||||||
ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}")
|
ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}")
|
||||||
if wind_speed:
|
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}°)。")
|
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:
|
if humidity and humidity > 80:
|
||||||
ai_features.append(f"💦 湿度极高 ({humidity}%)。")
|
ai_features.append(f"💦 湿度极高 ({humidity}%)。")
|
||||||
|
|
||||||
clouds = metar.get("current", {}).get("clouds", [])
|
clouds = primary_current.get("clouds", [])
|
||||||
if clouds:
|
if clouds:
|
||||||
cover = clouds[-1].get("cover", "")
|
cover = clouds[-1].get("cover", "")
|
||||||
c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(cover, cover)
|
c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(cover, cover)
|
||||||
ai_features.append(f"☁️ 天空状况: {c_desc}。")
|
ai_features.append(f"☁️ 天空状况: {c_desc}。")
|
||||||
|
|
||||||
wx_desc = metar.get("current", {}).get("wx_desc")
|
wx_desc = primary_current.get("wx_desc")
|
||||||
if wx_desc:
|
if wx_desc:
|
||||||
ai_features.append(f"🌧️ 天气现象: {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:
|
if max_so_far is not None and max_temp_time_str:
|
||||||
try:
|
try:
|
||||||
max_h = int(max_temp_time_str.split(":")[0])
|
max_h = int(max_temp_time_str.split(":")[0])
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ CITY_REGISTRY = {
|
|||||||
"lat": 22.3080,
|
"lat": 22.3080,
|
||||||
"lon": 113.9185,
|
"lon": 113.9185,
|
||||||
"icao": "VHHH",
|
"icao": "VHHH",
|
||||||
|
"settlement_source": "hko",
|
||||||
"tz_offset": 28800,
|
"tz_offset": 28800,
|
||||||
"use_fahrenheit": False,
|
"use_fahrenheit": False,
|
||||||
"is_major": True,
|
"is_major": True,
|
||||||
@@ -72,6 +73,21 @@ CITY_REGISTRY = {
|
|||||||
"distance_km": 31.0,
|
"distance_km": 31.0,
|
||||||
"warning": "海风与地形共同作用,午后对流触发后温度回落可能偏快。",
|
"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": {
|
"shanghai": {
|
||||||
"name": "Shanghai",
|
"name": "Shanghai",
|
||||||
"lat": 31.1434,
|
"lat": 31.1434,
|
||||||
@@ -296,6 +312,36 @@ CITY_REGISTRY = {
|
|||||||
"distance_km": 28.5,
|
"distance_km": 28.5,
|
||||||
"warning": "距离非常远,空旷机场夜间降温快,冬季易生大雾压制气温。",
|
"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 = {
|
ALIASES = {
|
||||||
@@ -305,11 +351,13 @@ ALIASES = {
|
|||||||
"dal": "dallas", "mia": "miami", "atl": "atlanta",
|
"dal": "dallas", "mia": "miami", "atl": "atlanta",
|
||||||
"sea": "seattle", "tor": "toronto", "sel": "seoul",
|
"sea": "seattle", "tor": "toronto", "sel": "seoul",
|
||||||
"seo": "seoul", "hkg": "hong kong", "hk": "hong kong",
|
"seo": "seoul", "hkg": "hong kong", "hk": "hong kong",
|
||||||
|
"tpe": "taipei", "tp": "taipei", "taipei": "taipei",
|
||||||
"sha": "shanghai", "sh": "shanghai", "sin": "singapore",
|
"sha": "shanghai", "sh": "shanghai", "sin": "singapore",
|
||||||
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
|
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
|
||||||
"tlv": "tel aviv", "telaviv": "tel aviv",
|
"tlv": "tel aviv", "telaviv": "tel aviv",
|
||||||
"ba": "buenos aires", "wel": "wellington",
|
"ba": "buenos aires", "wel": "wellington",
|
||||||
"luc": "lucknow", "sp": "sao paulo", "mun": "munich",
|
"luc": "lucknow", "sp": "sao paulo", "mun": "munich",
|
||||||
|
"mil": "milan", "mxp": "milan", "waw": "warsaw", "war": "warsaw",
|
||||||
|
|
||||||
# Chinese names
|
# Chinese names
|
||||||
"安卡拉": "ankara",
|
"安卡拉": "ankara",
|
||||||
@@ -324,6 +372,8 @@ ALIASES = {
|
|||||||
"多伦多": "toronto",
|
"多伦多": "toronto",
|
||||||
"首尔": "seoul",
|
"首尔": "seoul",
|
||||||
"香港": "hong kong",
|
"香港": "hong kong",
|
||||||
|
"台北": "taipei",
|
||||||
|
"臺北": "taipei",
|
||||||
"上海": "shanghai",
|
"上海": "shanghai",
|
||||||
"新加坡": "singapore",
|
"新加坡": "singapore",
|
||||||
"东京": "tokyo",
|
"东京": "tokyo",
|
||||||
@@ -334,4 +384,8 @@ ALIASES = {
|
|||||||
"勒克瑙": "lucknow",
|
"勒克瑙": "lucknow",
|
||||||
"圣保罗": "sao paulo",
|
"圣保罗": "sao paulo",
|
||||||
"慕尼黑": "munich",
|
"慕尼黑": "munich",
|
||||||
|
"米兰": "milan",
|
||||||
|
"米蘭": "milan",
|
||||||
|
"华沙": "warsaw",
|
||||||
|
"華沙": "warsaw",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import requests
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +33,14 @@ class WeatherDataCollector:
|
|||||||
"paris": ["LFPG", "LFPO", "LFPB"],
|
"paris": ["LFPG", "LFPO", "LFPB"],
|
||||||
"seoul": ["RKSI", "RKSS"],
|
"seoul": ["RKSI", "RKSS"],
|
||||||
"hong kong": ["VHHH", "VMMC", "ZGSZ"],
|
"hong kong": ["VHHH", "VMMC", "ZGSZ"],
|
||||||
|
"taipei": ["RCSS", "RCTP"],
|
||||||
"shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"],
|
"shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"],
|
||||||
"singapore": ["WSSS", "WSAP", "WMKK"],
|
"singapore": ["WSSS", "WSAP", "WMKK"],
|
||||||
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
|
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
|
||||||
"tel aviv": ["LLBG"],
|
"tel aviv": ["LLBG"],
|
||||||
|
"milan": ["LIMC", "LIML", "LIME", "LIPO"],
|
||||||
"toronto": ["CYYZ", "CYTZ", "CYKF"],
|
"toronto": ["CYYZ", "CYTZ", "CYKF"],
|
||||||
|
"warsaw": ["EPWA", "EPMO", "EPLL"],
|
||||||
"chicago": ["KORD", "KMDW", "KPWK", "KDPA"],
|
"chicago": ["KORD", "KMDW", "KPWK", "KDPA"],
|
||||||
"dallas": ["KDAL", "KDFW", "KADS", "KGKY"],
|
"dallas": ["KDAL", "KDFW", "KADS", "KGKY"],
|
||||||
"atlanta": ["KATL", "KPDK", "KFTY"],
|
"atlanta": ["KATL", "KPDK", "KFTY"],
|
||||||
@@ -86,6 +89,16 @@ class WeatherDataCollector:
|
|||||||
)
|
)
|
||||||
self._metar_cache: Dict[str, Dict] = {}
|
self._metar_cache: Dict[str, Dict] = {}
|
||||||
self._metar_cache_lock = threading.Lock()
|
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(
|
self._disk_cache_path = os.getenv(
|
||||||
@@ -222,6 +235,280 @@ class WeatherDataCollector:
|
|||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
self._open_meteo_last_call_ts = now_ts
|
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]:
|
def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Fetch current weather and forecast from OpenWeatherMap
|
Fetch current weather and forecast from OpenWeatherMap
|
||||||
@@ -1664,6 +1951,9 @@ class WeatherDataCollector:
|
|||||||
"hong kong": "Hong Kong",
|
"hong kong": "Hong Kong",
|
||||||
"hong kong international airport": "Hong Kong",
|
"hong kong international airport": "Hong Kong",
|
||||||
"香港": "Hong Kong",
|
"香港": "Hong Kong",
|
||||||
|
"taipei": "Taipei",
|
||||||
|
"台北": "Taipei",
|
||||||
|
"臺北": "Taipei",
|
||||||
"shanghai": "Shanghai",
|
"shanghai": "Shanghai",
|
||||||
"上海": "Shanghai",
|
"上海": "Shanghai",
|
||||||
"singapore": "Singapore",
|
"singapore": "Singapore",
|
||||||
@@ -1671,6 +1961,9 @@ class WeatherDataCollector:
|
|||||||
"tokyo": "Tokyo",
|
"tokyo": "Tokyo",
|
||||||
"东京": "Tokyo",
|
"东京": "Tokyo",
|
||||||
"東京": "Tokyo",
|
"東京": "Tokyo",
|
||||||
|
"milan": "Milan",
|
||||||
|
"米兰": "Milan",
|
||||||
|
"米蘭": "Milan",
|
||||||
"tel aviv": "Tel Aviv",
|
"tel aviv": "Tel Aviv",
|
||||||
"特拉维夫": "Tel Aviv",
|
"特拉维夫": "Tel Aviv",
|
||||||
"toronto": "Toronto",
|
"toronto": "Toronto",
|
||||||
@@ -1681,6 +1974,9 @@ class WeatherDataCollector:
|
|||||||
"惠灵顿": "Wellington",
|
"惠灵顿": "Wellington",
|
||||||
"buenos aires": "Buenos Aires",
|
"buenos aires": "Buenos Aires",
|
||||||
"布宜诺斯艾利斯": "Buenos Aires",
|
"布宜诺斯艾利斯": "Buenos Aires",
|
||||||
|
"warsaw": "Warsaw",
|
||||||
|
"华沙": "Warsaw",
|
||||||
|
"華沙": "Warsaw",
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, val in known_cities.items():
|
for key, val in known_cities.items():
|
||||||
@@ -1750,6 +2046,12 @@ class WeatherDataCollector:
|
|||||||
for key in list(self._metar_cache.keys()):
|
for key in list(self._metar_cache.keys()):
|
||||||
if key.startswith(prefix):
|
if key.startswith(prefix):
|
||||||
self._metar_cache.pop(key, None)
|
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(
|
def fetch_all_sources(
|
||||||
self,
|
self,
|
||||||
@@ -1813,6 +2115,10 @@ class WeatherDataCollector:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"🌡️ {city} 使用摄氏度 (°C)")
|
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:
|
if lat and lon:
|
||||||
open_meteo = self.fetch_from_open_meteo(
|
open_meteo = self.fetch_from_open_meteo(
|
||||||
lat, lon, use_fahrenheit=use_fahrenheit
|
lat, lon, use_fahrenheit=use_fahrenheit
|
||||||
|
|||||||
+59
-15
@@ -72,11 +72,19 @@ CITIES: Dict[str, Dict[str, Any]] = {
|
|||||||
"lat": info["lat"],
|
"lat": info["lat"],
|
||||||
"lon": info["lon"],
|
"lon": info["lon"],
|
||||||
"f": info["use_fahrenheit"],
|
"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()
|
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)
|
# Cache (5-min TTL)
|
||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
@@ -322,6 +330,11 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
info = CITIES[city]
|
info = CITIES[city]
|
||||||
lat, lon, is_f = info["lat"], info["lon"], info["f"]
|
lat, lon, is_f = info["lat"], info["lon"], info["f"]
|
||||||
sym = "°F" if is_f else "°C"
|
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 ──
|
# ── 1. Fetch raw data ──
|
||||||
raw = _weather.fetch_all_sources(
|
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", {})
|
om = raw.get("open-meteo", {})
|
||||||
metar = raw.get("metar", {})
|
metar = raw.get("metar", {})
|
||||||
mgm = raw.get("mgm") or {}
|
mgm = raw.get("mgm") or {}
|
||||||
|
settlement_current = raw.get("settlement_current") or {}
|
||||||
ens_raw = raw.get("ensemble", {})
|
ens_raw = raw.get("ensemble", {})
|
||||||
mm = raw.get("multi_model", {})
|
mm = raw.get("multi_model", {})
|
||||||
if not isinstance(om, dict):
|
if not isinstance(om, dict):
|
||||||
@@ -341,37 +355,54 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
metar = {}
|
metar = {}
|
||||||
if not isinstance(mgm, dict):
|
if not isinstance(mgm, dict):
|
||||||
mgm = {}
|
mgm = {}
|
||||||
|
if not isinstance(settlement_current, dict):
|
||||||
|
settlement_current = {}
|
||||||
if not isinstance(ens_raw, dict):
|
if not isinstance(ens_raw, dict):
|
||||||
ens_raw = {}
|
ens_raw = {}
|
||||||
if not isinstance(mm, dict):
|
if not isinstance(mm, dict):
|
||||||
mm = {}
|
mm = {}
|
||||||
risk = CITY_RISK_PROFILES.get(city, {})
|
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 {}
|
mc = metar.get("current", {}) if metar else {}
|
||||||
mg_cur = mgm.get("current", {}) if mgm 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()
|
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:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mg_cur.get("temp"))
|
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:
|
if max_so_far is None:
|
||||||
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
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:
|
if not max_temp_time:
|
||||||
max_temp_time = mg_cur.get("time", "")
|
max_temp_time = mg_cur.get("time", "")
|
||||||
if " " in max_temp_time:
|
if " " in max_temp_time:
|
||||||
max_temp_time = max_temp_time.split(" ")[1][:5]
|
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
|
wu_settle = wu_round(max_so_far) if max_so_far is not None else None
|
||||||
|
|
||||||
# Observation time → local
|
# Observation time → local
|
||||||
obs_time_str = ""
|
obs_time_str = ""
|
||||||
metar_age_min = None
|
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 动态偏移;最后回退静态配置
|
# 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置
|
||||||
utc_offset = om.get("utc_offset")
|
utc_offset = om.get("utc_offset")
|
||||||
if utc_offset is None:
|
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)
|
utc_offset = info.get("tz", 0)
|
||||||
if obs_t and "T" in obs_t:
|
if obs_t and "T" in obs_t:
|
||||||
try:
|
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)))
|
local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset)))
|
||||||
obs_time_str = local_dt.strftime("%H:%M")
|
obs_time_str = local_dt.strftime("%H:%M")
|
||||||
metar_age_min = int(
|
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:
|
except Exception:
|
||||||
obs_time_str = obs_t[:16]
|
obs_time_str = str(obs_t)[:16]
|
||||||
|
|
||||||
# ── 3. Local time parsing ──
|
# ── 3. Local time parsing ──
|
||||||
local_time_full = om.get("current", {}).get("local_time", "")
|
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_so_far": max_so_far,
|
||||||
"max_temp_time": max_temp_time,
|
"max_temp_time": max_temp_time,
|
||||||
"wu_settlement": wu_settle,
|
"wu_settlement": wu_settle,
|
||||||
|
"settlement_source": settlement_source,
|
||||||
|
"settlement_source_label": settlement_source_label,
|
||||||
"obs_time": obs_time_str,
|
"obs_time": obs_time_str,
|
||||||
"obs_age_min": metar_age_min,
|
"obs_age_min": metar_age_min,
|
||||||
"report_time": metar.get("report_time") if metar else None,
|
"report_time": metar.get("report_time") if metar else None,
|
||||||
"receipt_time": metar.get("receipt_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,
|
"obs_time_epoch": metar.get("obs_time_epoch") if metar else None,
|
||||||
"wind_speed_kt": _sf(mc.get("wind_speed_kt")),
|
"wind_speed_kt": _sf(primary_current.get("wind_speed_kt")),
|
||||||
"wind_dir": _sf(mc.get("wind_dir")),
|
"wind_dir": _sf(primary_current.get("wind_dir")),
|
||||||
"humidity": _sf(mc.get("humidity")),
|
"humidity": _sf(primary_current.get("humidity")),
|
||||||
"cloud_desc": cloud_desc,
|
"cloud_desc": cloud_desc,
|
||||||
"clouds_raw": [
|
"clouds_raw": [
|
||||||
{"cover": c.get("cover"), "base": c.get("base")} for c in clouds
|
{"cover": c.get("cover"), "base": c.get("base")} for c in clouds
|
||||||
],
|
],
|
||||||
"visibility_mi": _sf(mc.get("visibility_mi")),
|
"visibility_mi": _sf(primary_current.get("visibility_mi")),
|
||||||
"wx_desc": mc.get("wx_desc"),
|
"wx_desc": primary_current.get("wx_desc"),
|
||||||
"raw_metar": mc.get("raw_metar"),
|
"raw_metar": primary_current.get("raw_metar"),
|
||||||
},
|
},
|
||||||
"mgm": mgm_data,
|
"mgm": mgm_data,
|
||||||
"mgm_nearby": raw.get("mgm_nearby", []),
|
"mgm_nearby": raw.get("mgm_nearby", []),
|
||||||
@@ -912,6 +947,11 @@ async def list_cities(request: Request):
|
|||||||
"icao": risk.get("icao", ""),
|
"icao": risk.get("icao", ""),
|
||||||
"temp_unit": "fahrenheit" if info["f"] else "celsius",
|
"temp_unit": "fahrenheit" if info["f"] else "celsius",
|
||||||
"is_major": CITY_REGISTRY.get(name, {}).get("is_major", True),
|
"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}
|
return {"cities": out}
|
||||||
@@ -951,6 +991,8 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"current": {
|
"current": {
|
||||||
"temp": data.get("current", {}).get("temp"),
|
"temp": data.get("current", {}).get("temp"),
|
||||||
"obs_time": data.get("current", {}).get("obs_time"),
|
"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": {
|
"deb": {
|
||||||
"prediction": data.get("deb", {}).get("prediction"),
|
"prediction": data.get("deb", {}).get("prediction"),
|
||||||
@@ -1071,6 +1113,8 @@ def _build_city_detail_payload(
|
|||||||
"local_date": data.get("local_date"),
|
"local_date": data.get("local_date"),
|
||||||
"temp_symbol": data.get("temp_symbol"),
|
"temp_symbol": data.get("temp_symbol"),
|
||||||
"current_temp": data.get("current", {}).get("temp"),
|
"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"),
|
"deb_prediction": data.get("deb", {}).get("prediction"),
|
||||||
"risk_level": data.get("risk", {}).get("level"),
|
"risk_level": data.get("risk", {}).get("level"),
|
||||||
"risk_warning": data.get("risk", {}).get("warning"),
|
"risk_warning": data.get("risk", {}).get("warning"),
|
||||||
|
|||||||
Reference in New Issue
Block a user