37494a7192
将 web/routes.py 拆分为模块化 router + service 架构
- 新增 web/app_factory.py 集中注册 7 个域名 router
- 新增 web/routers/ 薄壳路由层(auth/city/system/scan/ops/payments/analytics)
- 新增 web/services/ 业务函数下沉(每域独立 service 文件)
- web/routes.py 缩减为 city_runtime 的兼容重导出 facade
- analysis_service.py/app.py 适配新入口并清理冗余导入
Scope-risk: LOW — 全量 170 测试通过,router 注册顺序与原路由一致
Tested: python -m pytest -q (170 passed), ruff check . (All checks passed)
@
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""City payload builders for API-facing response shapes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from src.analysis.settlement_rounding import apply_city_settlement
|
|
from web.core import _is_excluded_model_name, _market_layer, _sf
|
|
|
|
TURKISH_MGM_CITIES = {"ankara", "istanbul"}
|
|
|
|
|
|
def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {
|
|
"name": data.get("name"),
|
|
"display_name": data.get("display_name"),
|
|
"icao": data.get("risk", {}).get("icao"),
|
|
"utc_offset_seconds": data.get("utc_offset_seconds"),
|
|
"local_time": data.get("local_time"),
|
|
"temp_symbol": data.get("temp_symbol"),
|
|
"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")},
|
|
"deviation_monitor": data.get("deviation_monitor") or {},
|
|
"risk": {
|
|
"level": data.get("risk", {}).get("level"),
|
|
"warning": data.get("risk", {}).get("warning"),
|
|
},
|
|
"updated_at": data.get("updated_at"),
|
|
}
|
|
|
|
|
|
def build_city_market_scan_payload(
|
|
data: Dict[str, Any],
|
|
market_slug: Optional[str] = None,
|
|
target_date: Optional[str] = None,
|
|
lite: bool = False,
|
|
scan_filters: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
city = str(data.get("name") or "").strip().lower()
|
|
local_date = str(data.get("local_date") or "").strip()
|
|
requested_date = str(target_date or "").strip()
|
|
selected_date = requested_date or local_date
|
|
|
|
multi_model_daily = data.get("multi_model_daily") or {}
|
|
selected_daily = (
|
|
multi_model_daily.get(selected_date)
|
|
if isinstance(multi_model_daily, dict)
|
|
else None
|
|
)
|
|
if not isinstance(selected_daily, dict):
|
|
selected_daily = {}
|
|
selected_date = local_date
|
|
|
|
distribution = selected_daily.get("probabilities")
|
|
if not isinstance(distribution, list) or not distribution:
|
|
distribution = data.get("probabilities", {}).get("distribution", []) or []
|
|
distribution_all = selected_daily.get("probabilities_all")
|
|
if not isinstance(distribution_all, list) or not distribution_all:
|
|
distribution_all = data.get("probabilities", {}).get("distribution_all", []) or []
|
|
if not distribution_all:
|
|
distribution_all = distribution
|
|
|
|
model_map = selected_daily.get("models") or data.get("multi_model") or {}
|
|
if not isinstance(model_map, dict):
|
|
model_map = {}
|
|
|
|
anchor_temp = None
|
|
anchor_model = None
|
|
for model_name, raw_value in model_map.items():
|
|
value = _sf(raw_value)
|
|
if value is None:
|
|
continue
|
|
if anchor_temp is None or value > anchor_temp:
|
|
anchor_temp = value
|
|
anchor_model = str(model_name or "").strip() or None
|
|
|
|
anchor_temp_c = anchor_temp
|
|
temp_symbol = str(data.get("temp_symbol") or "")
|
|
if anchor_temp_c is not None and "F" in temp_symbol.upper():
|
|
anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0
|
|
anchor_settlement = apply_city_settlement(city, anchor_temp_c) if anchor_temp_c is not None else None
|
|
|
|
primary_bucket = None
|
|
if isinstance(distribution, list) and distribution:
|
|
ranked_buckets = []
|
|
temp_symbol_upper = str(temp_symbol or "").upper()
|
|
max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0
|
|
for idx, row in enumerate(distribution_all):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
bucket_value = _sf(
|
|
row.get("temp")
|
|
if row.get("temp") is not None
|
|
else row.get("value")
|
|
if row.get("value") is not None
|
|
else row.get("lower")
|
|
)
|
|
if (
|
|
anchor_temp is not None
|
|
and bucket_value is not None
|
|
and abs(float(bucket_value) - float(anchor_temp)) > max_primary_bucket_delta
|
|
):
|
|
continue
|
|
bucket_prob = _sf(row.get("probability"))
|
|
prob_rank = bucket_prob if bucket_prob is not None else -1.0
|
|
ranked_buckets.append((-prob_rank, idx, row))
|
|
if ranked_buckets:
|
|
ranked_buckets.sort(key=lambda x: (x[0], x[1]))
|
|
primary_bucket = ranked_buckets[0][2]
|
|
elif anchor_temp is None:
|
|
primary_bucket = distribution[0]
|
|
|
|
model_probability = None
|
|
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None:
|
|
try:
|
|
raw_probability = float(primary_bucket.get("probability"))
|
|
model_probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability
|
|
except Exception:
|
|
model_probability = None
|
|
|
|
fallback_sparkline = [
|
|
p.get("probability", 0)
|
|
for p in distribution_all[:8]
|
|
if isinstance(p, dict)
|
|
]
|
|
current = data.get("current") or {}
|
|
selected_deb = selected_daily.get("deb") if isinstance(selected_daily.get("deb"), dict) else {}
|
|
current_deb = data.get("deb") if isinstance(data.get("deb"), dict) else {}
|
|
scan_context = {
|
|
"local_date": data.get("local_date"),
|
|
"local_time": data.get("local_time"),
|
|
"peak": data.get("peak") or {},
|
|
"current_max_so_far": current.get("max_so_far"),
|
|
"current_temp": current.get("temp"),
|
|
"trend": data.get("trend") or {},
|
|
"network_lead_signal": data.get("network_lead_signal") or {},
|
|
"models": model_map,
|
|
"deb_prediction": selected_deb.get("prediction") or current_deb.get("prediction"),
|
|
}
|
|
market_scan = _market_layer.build_market_scan(
|
|
city=data.get("name"),
|
|
target_date=selected_date or data.get("local_date"),
|
|
temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None,
|
|
model_probability=model_probability,
|
|
probability_distribution=distribution_all,
|
|
temp_symbol=temp_symbol,
|
|
fallback_sparkline=fallback_sparkline,
|
|
forced_market_slug=market_slug,
|
|
include_related_buckets=not lite,
|
|
scan_filters=scan_filters,
|
|
scan_context=scan_context,
|
|
)
|
|
if isinstance(market_scan, dict):
|
|
market_scan["anchor_model"] = anchor_model
|
|
market_scan["anchor_high"] = anchor_temp
|
|
market_scan["anchor_settlement"] = anchor_settlement
|
|
market_scan["open_meteo_settlement"] = anchor_settlement
|
|
probabilities = data.get("probabilities") or {}
|
|
market_scan["probability_engine"] = str(
|
|
probabilities.get("engine") or "legacy"
|
|
).strip() or "legacy"
|
|
market_scan["probability_calibration_mode"] = str(
|
|
probabilities.get("calibration_mode") or "legacy"
|
|
).strip() or "legacy"
|
|
return {
|
|
"market_scan": market_scan,
|
|
"selected_date": selected_date or data.get("local_date"),
|
|
"fetched_at": data.get("updated_at"),
|
|
}
|
|
|
|
|
|
def build_city_detail_payload(
|
|
data: Dict[str, Any],
|
|
market_slug: Optional[str] = None,
|
|
target_date: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
market_payload = build_city_market_scan_payload(
|
|
data,
|
|
market_slug=market_slug,
|
|
target_date=target_date,
|
|
)
|
|
market_scan = market_payload.get("market_scan")
|
|
return {
|
|
"city": data.get("name"),
|
|
"fetched_at": data.get("updated_at"),
|
|
"overview": {
|
|
"name": data.get("name"),
|
|
"display_name": data.get("display_name"),
|
|
"icao": data.get("risk", {}).get("icao"),
|
|
"airport": data.get("risk", {}).get("airport"),
|
|
"lat": data.get("lat"),
|
|
"lon": data.get("lon"),
|
|
"local_time": data.get("local_time"),
|
|
"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"),
|
|
"settlement_station": data.get("settlement_station") or {},
|
|
"deb_prediction": data.get("deb", {}).get("prediction"),
|
|
"risk_level": data.get("risk", {}).get("level"),
|
|
"risk_warning": data.get("risk", {}).get("warning"),
|
|
"updated_at": data.get("updated_at"),
|
|
},
|
|
"official": {
|
|
"available": bool(data.get("current", {}).get("temp") is not None),
|
|
"metar": {
|
|
"observation_time": data.get("airport_current", {}).get("obs_time"),
|
|
"obs_age_min": data.get("airport_current", {}).get("obs_age_min"),
|
|
"report_time": data.get("airport_current", {}).get("report_time"),
|
|
"receipt_time": data.get("airport_current", {}).get("receipt_time"),
|
|
"raw_metar": data.get("airport_current", {}).get("raw_metar"),
|
|
"current": data.get("airport_current") or {},
|
|
},
|
|
"taf": data.get("taf") or {},
|
|
"weather_gov": {},
|
|
"mgm": data.get("mgm") or {},
|
|
"mgm_nearby": data.get("mgm_nearby") or [],
|
|
"nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"),
|
|
"airport_primary": data.get("airport_primary") or {},
|
|
"airport_primary_today_obs": data.get("airport_primary_today_obs") or [],
|
|
"official_nearby": data.get("official_nearby") or [],
|
|
"official_network_source": data.get("official_network_source"),
|
|
"official_network_status": data.get("official_network_status") or {},
|
|
"network_lead_signal": data.get("network_lead_signal") or {},
|
|
"network_spread_signal": data.get("network_spread_signal") or {},
|
|
"center_station_candidate": data.get("center_station_candidate"),
|
|
"airport_vs_network_delta": data.get("airport_vs_network_delta"),
|
|
},
|
|
"timeseries": {
|
|
"metar_recent_obs": data.get("metar_recent_obs") or [],
|
|
"metar_today_obs": data.get("metar_today_obs") or [],
|
|
"settlement_today_obs": data.get("settlement_today_obs") or [],
|
|
"hourly": data.get("hourly") or {},
|
|
"mgm_hourly": (data.get("mgm") or {}).get("hourly", []),
|
|
"forecast_daily": (data.get("forecast") or {}).get("daily", []),
|
|
},
|
|
"models": {
|
|
k: v
|
|
for k, v in (data.get("multi_model") or {}).items()
|
|
if not _is_excluded_model_name(k)
|
|
},
|
|
"deb": data.get("deb") or {},
|
|
"multi_model_daily": data.get("multi_model_daily") or {},
|
|
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
|
"dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
|
|
"intraday_meteorology": data.get("intraday_meteorology")
|
|
or _build_intraday_meteorology(data),
|
|
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
|
"taf": data.get("taf") or {},
|
|
"market_scan": market_scan,
|
|
"risk": data.get("risk"),
|
|
"settlement_station": data.get("settlement_station") or {},
|
|
"airport_primary": data.get("airport_primary") or {},
|
|
"official_nearby": data.get("official_nearby") or [],
|
|
"official_network_source": data.get("official_network_source"),
|
|
"official_network_status": data.get("official_network_status") or {},
|
|
"network_lead_signal": data.get("network_lead_signal") or {},
|
|
"network_spread_signal": data.get("network_spread_signal") or {},
|
|
"center_station_candidate": data.get("center_station_candidate"),
|
|
"airport_vs_network_delta": data.get("airport_vs_network_delta"),
|
|
"airport_current": data.get("airport_current") or {},
|
|
"nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"),
|
|
"ai_analysis": data.get("ai_analysis") or "",
|
|
"errors": {},
|
|
}
|
|
|
|
|
|
def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
from web.analysis_service import _build_intraday_meteorology as build_intraday
|
|
|
|
return build_intraday(data)
|