From 77b4d7b34177afd8d5a468623e2b9b02b87d67b6 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Sat, 11 Apr 2026 20:11:01 +0800
Subject: [PATCH] feat: implement METAR data collection service and dashboard
infrastructure
---
.env.example | 8 +-
data/probability_training_snapshots.jsonl | 27 ++
frontend/app/api/city/[name]/route.ts | 3 +-
.../dashboard/PolyWeatherDashboard.tsx | 11 +-
frontend/hooks/useDashboardStore.tsx | 168 ++++++---
frontend/lib/dashboard-client.ts | 15 +-
frontend/lib/dashboard-types.ts | 1 +
src/data_collection/city_registry.py | 17 +-
src/data_collection/metar_sources.py | 12 +-
src/data_collection/nws_open_meteo_sources.py | 6 +-
src/data_collection/settlement_sources.py | 44 ++-
src/data_collection/weather_sources.py | 142 +++++---
web/analysis_service.py | 340 +++++++++++++++++-
web/routes.py | 14 +-
14 files changed, 660 insertions(+), 148 deletions(-)
diff --git a/.env.example b/.env.example
index d678ff3b..a5e39140 100644
--- a/.env.example
+++ b/.env.example
@@ -45,7 +45,13 @@ OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
-OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
+OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
+POLYWEATHER_HTTP_TIMEOUT_SEC=8
+POLYWEATHER_HTTP_RETRY_COUNT=0
+POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
+POLYWEATHER_OPEN_METEO_TIMEOUT_SEC=5
+POLYWEATHER_METAR_TIMEOUT_SEC=4
+POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
METAR_CACHE_TTL_SEC=600
METEOBLUE_CACHE_TTL_SEC=7200
POLYWEATHER_LGBM_ENABLED=false
diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl
index bde941b5..adb59a1f 100644
--- a/data/probability_training_snapshots.jsonl
+++ b/data/probability_training_snapshots.jsonl
@@ -126,3 +126,30 @@
{"city": "chengdu", "timestamp": "2026-04-08T07:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 1.1821289062499998, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 23.0, "p90": 24.5}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 23.0, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 24.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 15.183333333333334}, "peak_status": "before", "prob_snapshot": [{"v": 24, "p": 0.442}, {"v": 25, "p": 0.386}, {"v": 26, "p": 0.172}], "shadow_prob_snapshot": [{"v": 24, "p": 0.394}, {"v": 25, "p": 0.355}, {"v": 26, "p": 0.19}, {"v": 27, "p": 0.06}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 1.354078466151897}
{"city": "chengdu", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 24.3, "raw_sigma": 0.7283767361111106, "deb_prediction": 23.1, "ensemble": {"p10": 21.8, "median": 22.9, "p90": 24.2}, "multi_model": {"Open-Meteo": 22.5, "ECMWF": 23.9, "GFS": 22.5, "ICON": 22.5, "GEM": 23.7, "JMA": 23.2}, "max_so_far": 24.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 16.133333333333333}, "peak_status": "in_window", "prob_snapshot": [{"v": 24, "p": 0.547}, {"v": 25, "p": 0.397}, {"v": 26, "p": 0.056}], "shadow_prob_snapshot": [{"v": 24, "p": 0.521}, {"v": 25, "p": 0.399}, {"v": 26, "p": 0.08}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 24.3, "calibrated_sigma": 0.8140063140878262}
{"city": "tokyo", "timestamp": "2026-04-08T08:00:00.000Z", "date": "2026-04-08", "temp_symbol": "°C", "raw_mu": 17.810000000000002, "raw_sigma": 0.23200683593750038, "deb_prediction": 17.1, "ensemble": {"p10": 17.4, "median": 18.3, "p90": 19.1}, "multi_model": {"Open-Meteo": 16.1, "ECMWF": 16.3, "GFS": 17.6, "ICON": 18.2, "GEM": 18.3, "JMA": 16.1}, "max_so_far": 17.0, "observation": {"current_temp": 16.0, "humidity": null, "wind_speed_kt": 17.0, "visibility_mi": null, "local_hour": 17.133333333333333}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 0.909}, {"v": 17, "p": 0.091}], "shadow_prob_snapshot": [{"v": 18, "p": 0.892}, {"v": 17, "p": 0.108}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 17.810000000000002, "calibrated_sigma": 0.25}
+{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.3, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
+{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.8, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.3, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.45}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.654}, {"v": 12, "p": 0.346}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.46787549747087}
+{"city": "hong kong", "timestamp": "2026-04-11T19:10:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.10084635416666676, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 4.3, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.839}, {"v": 28, "p": 0.161}], "shadow_prob_snapshot": [{"v": 27, "p": 0.769}, {"v": 28, "p": 0.231}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13614257812500014}
+{"city": "taipei", "timestamp": "2026-04-11T11:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10876464843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 2.0, "visibility_mi": null, "local_hour": 19.45}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.1, "raw_sigma": 0.8500000000000005, "deb_prediction": 10.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.582}, {"v": 12, "p": 0.355}, {"v": 13, "p": 0.063}], "shadow_prob_snapshot": [{"v": 11, "p": 0.563}, {"v": 12, "p": 0.361}, {"v": 13, "p": 0.076}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.1, "calibrated_sigma": 0.9012761535978395}
+{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.85}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
+{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.375, "deb_prediction": 27.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.603}, {"v": 28, "p": 0.397}], "shadow_prob_snapshot": [{"v": 27, "p": 0.597}, {"v": 28, "p": 0.403}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.39311002429138}
+{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.85}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.7199999999999995, "deb_prediction": 29.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.866666666666667}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.883333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
+{"city": "ankara", "timestamp": "2026-04-11T11:20:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 6.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
+{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
+{"city": "istanbul", "timestamp": "2026-04-11T14:20:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.42968750000000056, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 11.0, "humidity": 57.8, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.9}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.671}, {"v": 12, "p": 0.329}], "shadow_prob_snapshot": [{"v": 11, "p": 0.656}, {"v": 12, "p": 0.344}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4631927411757732}
+{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3299999999999999, "deb_prediction": 27.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.33392493649594973}
+{"city": "hong kong", "timestamp": "2026-04-11T19:40:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09750000000000009, "deb_prediction": 27.3, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.7, "humidity": 82.0, "wind_speed_kt": 2.7, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.847}, {"v": 28, "p": 0.153}], "shadow_prob_snapshot": [{"v": 27, "p": 0.776}, {"v": 28, "p": 0.224}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.13162500000000013}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 19.9}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 1.0, "deb_prediction": 7.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 7.5}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.475}, {"v": 9, "p": 0.395}, {"v": 10, "p": 0.131}], "shadow_prob_snapshot": [{"v": 8, "p": 0.451}, {"v": 9, "p": 0.389}, {"v": 10, "p": 0.16}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 1.1241186693749348}
+{"city": "ankara", "timestamp": "2026-04-11T12:00:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 8.3, "raw_sigma": 0.5468749999999998, "deb_prediction": 7.9, "ensemble": {"p10": 6.8, "median": 7.4, "p90": 8.2}, "multi_model": {"Open-Meteo": 7.5, "ECMWF": 7.5, "GFS": 8.2, "ICON": 7.5, "GEM": 8.2, "JMA": 8.3}, "max_so_far": 8.0, "observation": {"current_temp": 8.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 8, "p": 0.615}, {"v": 9, "p": 0.37}, {"v": 10, "p": 0.015}], "shadow_prob_snapshot": [{"v": 8, "p": 0.594}, {"v": 9, "p": 0.381}, {"v": 10, "p": 0.024}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 8.3, "calibrated_sigma": 0.5978357923824302}
+{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 1.0, "deb_prediction": 11.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 11.3}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 14.983333333333333}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.475}, {"v": 12, "p": 0.395}, {"v": 13, "p": 0.131}], "shadow_prob_snapshot": [{"v": 11, "p": 0.469}, {"v": 12, "p": 0.394}, {"v": 13, "p": 0.137}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 1.0267976073543543}
+{"city": "istanbul", "timestamp": "2026-04-11T14:50:00+03:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 11.3, "raw_sigma": 0.4494809751157413, "deb_prediction": 10.9, "ensemble": {"p10": 9.7, "median": 10.4, "p90": 10.8}, "multi_model": {"Open-Meteo": 11.3, "ECMWF": 11.0, "GFS": 10.8, "ICON": 11.3, "GEM": 11.1, "JMA": 9.6}, "max_so_far": 11.0, "observation": {"current_temp": 10.0, "humidity": 57.5, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 15.0}, "peak_status": "before", "prob_snapshot": [{"v": 11, "p": 0.662}, {"v": 12, "p": 0.338}], "shadow_prob_snapshot": [{"v": 11, "p": 0.647}, {"v": 12, "p": 0.353}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 11.3, "calibrated_sigma": 0.4837415114258855}
+{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 28.999999999999996, "raw_sigma": 0.3, "deb_prediction": 29.0, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"HKO(港天文)": 29.0}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "shadow_prob_snapshot": [{"v": 28, "p": 0.5}, {"v": 29, "p": 0.5}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 28.999999999999996, "calibrated_sigma": 0.29193323618665046}
+{"city": "hong kong", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": 27.900000000000002, "raw_sigma": 0.09375000000000008, "deb_prediction": 27.4, "ensemble": {"p10": 27.0, "median": 27.3, "p90": 27.8}, "multi_model": {"HKO(港天文)": 29.0, "ECMWF": 26.7, "GFS": 26.5, "ICON": 26.8, "GEM": 27.2, "JMA": 28.2}, "max_so_far": 27.6, "observation": {"current_temp": 26.8, "humidity": 81.0, "wind_speed_kt": 3.2, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 0.857}, {"v": 28, "p": 0.143}], "shadow_prob_snapshot": [{"v": 27, "p": 0.785}, {"v": 28, "p": 0.215}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 27.900000000000002, "calibrated_sigma": 0.12656250000000013}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.3, "deb_prediction": 27.6, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 27.6}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "taipei", "timestamp": "2026-04-11T11:30:00.000Z", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843750025, "deb_prediction": 29.5, "ensemble": {"p10": 28.9, "median": 29.4, "p90": 29.8}, "multi_model": {"Open-Meteo": 27.6, "ECMWF": 31.0, "GFS": 32.4, "ICON": 27.6, "GEM": 29.7, "JMA": 28.9}, "max_so_far": 33.0, "observation": {"current_temp": 27.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": null, "local_hour": 20.0}, "peak_status": "past", "prob_snapshot": [{"v": 33, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "taipei", "timestamp": "2026-04-11T19:50:00+08:00", "date": "2026-04-11", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10938964843749983, "deb_prediction": 30.3, "ensemble": {"p10": 28.8, "median": 29.3, "p90": 29.7}, "multi_model": {"Open-Meteo": 29.7, "ECMWF": 30.9, "GFS": 32.4, "ICON": 29.7, "GEM": 29.6, "JMA": 29.3}, "max_so_far": 32.1, "observation": {"current_temp": 27.6, "humidity": 73.0, "wind_speed_kt": 2.9, "visibility_mi": null, "local_hour": 20.1}, "peak_status": "past", "prob_snapshot": [{"v": 32, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts
index 7331cedf..e5d6856c 100644
--- a/frontend/app/api/city/[name]/route.ts
+++ b/frontend/app/api/city/[name]/route.ts
@@ -20,7 +20,8 @@ export async function GET(
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
- const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
+ const depth = req.nextUrl.searchParams.get("depth") ?? "panel";
+ const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`;
try {
const auth = await buildBackendRequestHeaders(req, {
diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx
index ba356730..51aff181 100644
--- a/frontend/components/dashboard/PolyWeatherDashboard.tsx
+++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx
@@ -45,8 +45,12 @@ const FutureForecastModal = dynamic(
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
+ const activeSummary = store.selectedCity
+ ? store.citySummariesByName[store.selectedCity] || null
+ : null;
const activeCityName =
store.selectedDetail?.display_name ||
+ activeSummary?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
store.selectedCity ||
"";
@@ -77,6 +81,11 @@ function DashboardScreen() {
const showLoading =
store.loadingState.cities ||
store.loadingState.refresh;
+ const showCitySyncToast =
+ store.loadingState.cityDetail &&
+ activeCityName &&
+ !store.selectedDetail &&
+ !activeSummary;
return (
@@ -84,7 +93,7 @@ function DashboardScreen() {
- {store.loadingState.cityDetail && activeCityName ? (
+ {showCitySyncToast ? (
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx
index fffbfe53..6afa6788 100644
--- a/frontend/hooks/useDashboardStore.tsx
+++ b/frontend/hooks/useDashboardStore.tsx
@@ -30,10 +30,14 @@ interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void;
- ensureCityDetail: (cityName: string, force?: boolean) => Promise;
+ ensureCityDetail: (
+ cityName: string,
+ force?: boolean,
+ depth?: "panel" | "full",
+ ) => Promise;
futureModalDate: string | null;
loadCities: () => Promise;
- openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
+ openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise;
openHistory: () => Promise;
openTodayModal: (forceRefresh?: boolean) => Promise;
registerMapStopMotion: (stopMotion: () => void) => void;
@@ -90,6 +94,7 @@ const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
const EAGER_CITY_SUMMARIES_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES === "true";
+type CityDetailDepth = "panel" | "full";
function countAvailableModels(
detail?: CityDetail | null,
@@ -128,6 +133,19 @@ function hasSparseDetailCoverage(
);
}
+function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth {
+ return detail?.detail_depth === "panel" ? "panel" : "full";
+}
+
+function detailSatisfiesDepth(
+ detail: CityDetail | null | undefined,
+ depth: CityDetailDepth,
+) {
+ if (!detail) return false;
+ if (depth === "panel") return true;
+ return normalizeDetailDepth(detail) === "full";
+}
+
export function DashboardStoreProvider({
children,
}: {
@@ -171,6 +189,7 @@ export function DashboardStoreProvider({
const hydratedProCacheRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef>({});
const citySummariesRef = useRef>({});
+ const selectedCityRef = useRef(null);
const selectedDetail =
selectedCity && proAccess.subscriptionActive
? cityDetailsByName[selectedCity] || null
@@ -210,6 +229,10 @@ export function DashboardStoreProvider({
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
+ useEffect(() => {
+ selectedCityRef.current = selectedCity;
+ }, [selectedCity]);
+
useEffect(() => {
proAccessRef.current = proAccess;
}, [proAccess]);
@@ -273,6 +296,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
+ depth: normalizeDetailDepth(cached),
});
const detail = latestDetail;
@@ -295,13 +319,19 @@ export function DashboardStoreProvider({
.catch(() => {});
};
- const ensureCityDetail = async (cityName: string, force = false) => {
+ const ensureCityDetail = async (
+ cityName: string,
+ force = false,
+ depth: CityDetailDepth = "panel",
+ ) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
+ const hasRequestedDepth = detailSatisfiesDepth(cached, depth);
const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date);
if (
!force &&
cached &&
+ hasRequestedDepth &&
!cachedIsSparse &&
dashboardClient.isCityDetailFresh(cachedMeta)
) {
@@ -309,7 +339,7 @@ export function DashboardStoreProvider({
return cached;
}
- if (!force && cached) {
+ if (!force && cached && hasRequestedDepth) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary);
@@ -317,6 +347,7 @@ export function DashboardStoreProvider({
if (cachedIsSparse) {
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: true,
+ depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
@@ -352,6 +383,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
+ depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
@@ -381,7 +413,7 @@ export function DashboardStoreProvider({
let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true }));
- void ensureCityDetail(selectedCity, false)
+ void ensureCityDetail(selectedCity, false, "panel")
.then((detail) => {
if (cancelled) return;
setSelectedForecastDate(detail.local_date);
@@ -577,26 +609,35 @@ export function DashboardStoreProvider({
};
}, [cities]);
+ const ensureCitySummary = async (cityName: string, force = false) => {
+ const existing = citySummariesRef.current[cityName];
+ if (!force && existing) {
+ return existing;
+ }
+ const summary = await dashboardClient.getCitySummary(cityName, { force });
+ setCitySummariesByName((current) => ({
+ ...current,
+ [cityName]: summary,
+ }));
+ return summary;
+ };
+
const selectCity = async (cityName: string) => {
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(null);
setFutureModalDate(null);
+ const summaryPromise = !citySummariesRef.current[cityName]
+ ? ensureCitySummary(cityName).catch(() => null)
+ : Promise.resolve(citySummariesRef.current[cityName]);
+
if (proAccessRef.current.loading) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
- if (!citySummariesRef.current[cityName]) {
- try {
- const summary = await dashboardClient.getCitySummary(cityName);
- setCitySummariesByName((current) => ({
- ...current,
- [cityName]: summary,
- }));
- } catch {
- } finally {
- setLoadingState((current) => ({ ...current, cityDetail: false }));
- }
- } else {
+ try {
+ await summaryPromise;
+ } catch {
+ } finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
@@ -605,18 +646,10 @@ export function DashboardStoreProvider({
const access = proAccessRef.current;
if (!access.authenticated || !access.subscriptionActive) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
- if (!citySummariesRef.current[cityName]) {
- try {
- const summary = await dashboardClient.getCitySummary(cityName);
- setCitySummariesByName((current) => ({
- ...current,
- [cityName]: summary,
- }));
- } catch {
- } finally {
- setLoadingState((current) => ({ ...current, cityDetail: false }));
- }
- } else {
+ try {
+ await summaryPromise;
+ } catch {
+ } finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
@@ -629,20 +662,28 @@ export function DashboardStoreProvider({
);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
- const detail = await ensureCityDetail(cityName, needsDetailRefresh);
- setSelectedForecastDate(detail.local_date);
- if (access.authenticated && access.subscriptionActive) {
- // 预热市场数据,不做 await 阻塞,后台静默拉取
- void ensureCityMarketScan(
- cityName,
- false,
- null,
- detail.local_date,
- ).catch(() => {});
- }
- } finally {
- setLoadingState((current) => ({ ...current, cityDetail: false }));
+ await summaryPromise;
+ } catch {
}
+
+ void ensureCityDetail(cityName, needsDetailRefresh, "panel")
+ .then((detail) => {
+ if (selectedCityRef.current !== cityName) return;
+ setSelectedForecastDate(detail.local_date);
+ if (access.authenticated && access.subscriptionActive) {
+ // 预热市场数据,不做 await 阻塞,后台静默拉取
+ void ensureCityMarketScan(
+ cityName,
+ false,
+ null,
+ detail.local_date,
+ ).catch(() => {});
+ }
+ })
+ .finally(() => {
+ if (selectedCityRef.current !== cityName) return;
+ setLoadingState((current) => ({ ...current, cityDetail: false }));
+ });
};
useEffect(() => {
@@ -678,7 +719,7 @@ export function DashboardStoreProvider({
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
- const detail = await ensureCityDetail(selectedCity, true);
+ const detail = await ensureCityDetail(selectedCity, true, "panel");
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
@@ -696,6 +737,7 @@ export function DashboardStoreProvider({
if (access.authenticated && access.subscriptionActive) {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
+ depth: "panel",
});
const detail = latestDetail;
setCityDetailsByName({ [selectedCity]: detail });
@@ -779,16 +821,31 @@ export function DashboardStoreProvider({
loadCities,
loadingState,
proAccess,
- openFutureModal: (dateStr: string, forceRefresh = false) => {
+ openFutureModal: async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
- setFutureModalDate(dateStr);
if (!selectedCity || !proAccess.subscriptionActive) return;
const cachedDetail = cityDetailsByName[selectedCity];
- const needsDetailRefresh =
- !forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr);
- if (needsDetailRefresh) {
- void ensureCityDetail(selectedCity, true).catch(() => {});
+ const hasFullCachedDetail =
+ detailSatisfiesDepth(cachedDetail, "full") &&
+ !hasSparseDetailCoverage(cachedDetail, dateStr);
+
+ if (!hasFullCachedDetail || forceRefresh) {
+ setLoadingState((current) => ({
+ ...current,
+ refresh: true,
+ }));
+ try {
+ await ensureCityDetail(selectedCity, true, "full");
+ } catch {
+ } finally {
+ setLoadingState((current) => ({
+ ...current,
+ refresh: false,
+ }));
+ }
}
+
+ setFutureModalDate(dateStr);
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
@@ -810,25 +867,30 @@ export function DashboardStoreProvider({
mapStopMotionRef.current();
const cachedDetail = cityDetailsByName[selectedCity];
- if (cachedDetail?.local_date) {
+ const hasFullCachedDetail =
+ detailSatisfiesDepth(cachedDetail, "full") &&
+ !hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
+ if (hasFullCachedDetail && cachedDetail?.local_date) {
setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date);
}
if (!proAccess.subscriptionActive) return;
const needsDetailRefresh =
- !forceRefresh &&
+ forceRefresh ||
+ !detailSatisfiesDepth(cachedDetail, "full") ||
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
setLoadingState((current) => ({
...current,
- refresh: !cachedDetail?.local_date,
+ refresh: needsDetailRefresh,
marketScan: true,
}));
try {
const detail = await ensureCityDetail(
selectedCity,
- Boolean(forceRefresh || needsDetailRefresh),
+ needsDetailRefresh,
+ "full",
);
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);
diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts
index c9bc224c..f0a39631 100644
--- a/frontend/lib/dashboard-client.ts
+++ b/frontend/lib/dashboard-client.ts
@@ -29,6 +29,10 @@ function normalizeCityName(cityName: string) {
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
}
+function normalizeDetailDepth(depth?: "panel" | "full") {
+ return depth === "full" ? "full" : "panel";
+}
+
async function fetchJson(url: string): Promise {
const response = await fetch(url, {
headers: { Accept: "application/json" },
@@ -155,17 +159,21 @@ export const dashboardClient = {
return request;
},
- async getCityDetail(cityName: string, options?: { force?: boolean }) {
+ async getCityDetail(
+ cityName: string,
+ options?: { force?: boolean; depth?: "panel" | "full" },
+ ) {
const force = options?.force ?? false;
+ const depth = normalizeDetailDepth(options?.depth);
if (!force) {
- const requestKey = `${cityName}::cached`;
+ const requestKey = `${cityName}::${depth}::cached`;
const existing = pendingCityDetailRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetchJson(
- `/api/city/${normalizeCityName(cityName)}?force_refresh=false`,
+ `/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
});
@@ -176,6 +184,7 @@ export const dashboardClient = {
const params = new URLSearchParams({
force_refresh: "true",
+ depth,
_ts: String(Date.now()),
});
return fetchJson(
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 84474b9d..991b16e8 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -327,6 +327,7 @@ export interface AiAnalysisStructured {
export interface CityDetail {
name: string;
display_name: string;
+ detail_depth?: "panel" | "full";
lat: number;
lon: number;
temp_symbol: string;
diff --git a/src/data_collection/city_registry.py b/src/data_collection/city_registry.py
index 5e47f9ed..32514080 100644
--- a/src/data_collection/city_registry.py
+++ b/src/data_collection/city_registry.py
@@ -149,21 +149,20 @@ CITY_REGISTRY = {
},
"taipei": {
"name": "Taipei",
- "lat": 25.0670,
- "lon": 121.5525,
+ "lat": 25.0377,
+ "lon": 121.5149,
"icao": "RCSS",
- "settlement_source": "wunderground",
- "settlement_station_code": "RCSS",
- "settlement_station_label": "Taipei Songshan Airport Station",
- "settlement_url": "https://www.wunderground.com/history/daily/tw/taipei/RCSS",
+ "settlement_source": "cwa",
+ "settlement_station_code": "466920",
+ "settlement_station_label": "中央气象署台北站",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "low",
"risk_emoji": "🟢",
- "airport_name": "台北松山机场",
- "distance_km": 4.1,
- "warning": "市场现按 Wunderground 台北松山机场站整度°C口径结算;以历史页当日最终完成后的最高整度摄氏值为准。",
+ "airport_name": "中央气象署台北站",
+ "distance_km": 0.0,
+ "warning": "结算按交通部中央气象署台北站口径,不应混用松山机场 METAR 作为结算主源。",
},
"shanghai": {
"name": "Shanghai",
diff --git a/src/data_collection/metar_sources.py b/src/data_collection/metar_sources.py
index 174d22de..98a49510 100644
--- a/src/data_collection/metar_sources.py
+++ b/src/data_collection/metar_sources.py
@@ -248,7 +248,11 @@ class MetarSourceMixin:
"hours": 24,
"_t": int(time.time()),
}
- response = self.session.get(url, params=params, timeout=self.timeout)
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=getattr(self, "metar_timeout_sec", self.timeout),
+ )
response.raise_for_status()
data = response.json()
if not data:
@@ -295,7 +299,11 @@ class MetarSourceMixin:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
- resp = self.session.get(url, headers=headers, timeout=self.timeout)
+ resp = self.session.get(
+ url,
+ headers=headers,
+ timeout=getattr(self, "metar_cluster_timeout_sec", self.timeout),
+ )
if resp.status_code != 200:
logger.warning(f"METAR cluster fetch HTTP {resp.status_code} for {icaos}")
return []
diff --git a/src/data_collection/nws_open_meteo_sources.py b/src/data_collection/nws_open_meteo_sources.py
index b225b40a..a68c91d0 100644
--- a/src/data_collection/nws_open_meteo_sources.py
+++ b/src/data_collection/nws_open_meteo_sources.py
@@ -208,7 +208,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get(
url,
params=params,
- timeout=self.timeout,
+ timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
)
response.raise_for_status()
data = response.json()
@@ -369,7 +369,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get(
url,
params=params,
- timeout=self.timeout,
+ timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
)
response.raise_for_status()
data = response.json()
@@ -526,7 +526,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get(
url,
params=params,
- timeout=self.timeout,
+ timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
)
response.raise_for_status()
data = response.json()
diff --git a/src/data_collection/settlement_sources.py b/src/data_collection/settlement_sources.py
index 4368be19..04d50de8 100644
--- a/src/data_collection/settlement_sources.py
+++ b/src/data_collection/settlement_sources.py
@@ -4,6 +4,7 @@ import csv
import math
import threading
import time
+from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
@@ -226,19 +227,31 @@ class SettlementSourceMixin:
try:
base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather"
- temp_csv = self._http_get(f"{base}/latest_1min_temperature.csv", timeout=self.timeout)
- temp_csv.raise_for_status()
- maxmin_csv = self._http_get(f"{base}/latest_since_midnight_maxmin.csv", timeout=self.timeout)
- maxmin_csv.raise_for_status()
- humidity_csv = self._http_get(f"{base}/latest_1min_humidity.csv", timeout=self.timeout)
- humidity_csv.raise_for_status()
- wind_csv = self._http_get(f"{base}/latest_10min_wind.csv", timeout=self.timeout)
- wind_csv.raise_for_status()
+ csv_urls = {
+ "temp": f"{base}/latest_1min_temperature.csv",
+ "maxmin": f"{base}/latest_since_midnight_maxmin.csv",
+ "humidity": f"{base}/latest_1min_humidity.csv",
+ "wind": f"{base}/latest_10min_wind.csv",
+ }
- 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)
+ def _fetch_csv(url: str):
+ response = self._http_get(url, timeout=self.timeout)
+ response.raise_for_status()
+ return response
+
+ fetched_csv = {}
+ with ThreadPoolExecutor(max_workers=4) as executor:
+ future_map = {
+ executor.submit(_fetch_csv, url): key
+ for key, url in csv_urls.items()
+ }
+ for future, key in future_map.items():
+ fetched_csv[key] = future.result()
+
+ temp_rows = self._csv_rows(fetched_csv["temp"].text)
+ maxmin_rows = self._csv_rows(fetched_csv["maxmin"].text)
+ humidity_rows = self._csv_rows(fetched_csv["humidity"].text)
+ wind_rows = self._csv_rows(fetched_csv["wind"].text)
temp_row = self._pick_station_row(temp_rows, candidate_names)
maxmin_row = self._pick_station_row(maxmin_rows, candidate_names)
@@ -626,6 +639,8 @@ class SettlementSourceMixin:
station_name=station_name,
station_candidates=station_candidates,
)
+ if settlement_source == "cwa":
+ return self.fetch_cwa_taipei_settlement_current()
if settlement_source == "noaa":
station_code = (
str(city_meta.get("settlement_station_code") or "").strip()
@@ -643,8 +658,5 @@ class SettlementSourceMixin:
except Exception as exc:
logger.warning(f"Settlement source dispatch failed city={city}: {exc}")
if normalized == "taipei":
- return self.fetch_noaa_station_settlement_current(
- station_code="RCTP",
- station_name="Taiwan Taoyuan International Airport",
- )
+ return self.fetch_cwa_taipei_settlement_current()
return None
diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py
index 08c742d2..31c7de90 100644
--- a/src/data_collection/weather_sources.py
+++ b/src/data_collection/weather_sources.py
@@ -113,12 +113,24 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
def __init__(self, config: dict):
self.config = config
- self.timeout = 30 # 增加超时以支持高延迟 VPS
+ # Keep external calls short so one degraded source cannot block the whole city pipeline.
+ self.timeout = max(
+ 2.0, float(os.getenv("POLYWEATHER_HTTP_TIMEOUT_SEC", "8"))
+ )
self.http_retry_count = max(
- 0, int(os.getenv("POLYWEATHER_HTTP_RETRY_COUNT", "1"))
+ 0, int(os.getenv("POLYWEATHER_HTTP_RETRY_COUNT", "0"))
)
self.http_retry_backoff_sec = max(
- 0.0, float(os.getenv("POLYWEATHER_HTTP_RETRY_BACKOFF_SEC", "0.35"))
+ 0.0, float(os.getenv("POLYWEATHER_HTTP_RETRY_BACKOFF_SEC", "0.2"))
+ )
+ self.open_meteo_timeout_sec = max(
+ 2.0, float(os.getenv("POLYWEATHER_OPEN_METEO_TIMEOUT_SEC", "5"))
+ )
+ self.metar_timeout_sec = max(
+ 2.0, float(os.getenv("POLYWEATHER_METAR_TIMEOUT_SEC", "4"))
+ )
+ self.metar_cluster_timeout_sec = max(
+ 2.0, float(os.getenv("POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC", "3.5"))
)
self.session = httpx.Client(
timeout=self.timeout,
@@ -151,7 +163,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._open_meteo_rl_lock = threading.Lock()
# Open-Meteo burst control: avoid hammering API with many cities at once.
self._open_meteo_min_interval_sec: float = float(
- os.getenv("OPEN_METEO_MIN_CALL_INTERVAL_SEC", "3")
+ os.getenv("OPEN_METEO_MIN_CALL_INTERVAL_SEC", "1")
)
self._open_meteo_last_call_ts: float = 0.0
self._open_meteo_call_lock = threading.Lock()
@@ -728,8 +740,18 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
hko_forecast = self.fetch_hko_forecast()
if hko_forecast:
results["hko_forecast"] = hko_forecast
+ elif settlement_source == "cwa":
+ cwa_forecast = self.fetch_cwa_taipei_forecast()
+ if cwa_forecast is not None:
+ results["cwa_forecast"] = cwa_forecast
- def _attach_turkish_mgm_data(self, results: Dict, city_lower: str) -> None:
+ def _attach_turkish_mgm_data(
+ self,
+ results: Dict,
+ city_lower: str,
+ *,
+ include_nearby: bool = True,
+ ) -> None:
if city_lower not in self.TURKISH_PROVINCES:
return
istno, province = self.TURKISH_PROVINCES[city_lower]
@@ -737,15 +759,22 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
if not mgm_data:
return
results["mgm"] = mgm_data
- results["nearby_source"] = "mgm"
- nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
- if nearby:
- results["mgm_nearby"] = nearby
+ if include_nearby:
+ results["nearby_source"] = "mgm"
+ nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
+ if nearby:
+ results["mgm_nearby"] = nearby
def _attach_global_nearby_cluster(
self, results: Dict, city_lower: str, use_fahrenheit: bool
) -> None:
- if city_lower not in self.CITY_METAR_CLUSTERS or "mgm_nearby" in results:
+ city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower()) or {}
+ settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
+ if (
+ city_lower not in self.CITY_METAR_CLUSTERS
+ or "mgm_nearby" in results
+ or settlement_source in {"hko", "cwa"}
+ ):
return
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
cluster_data = self.fetch_metar_nearby_cluster(
@@ -854,21 +883,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lat: float,
lon: float,
use_fahrenheit: bool,
+ *,
+ include_ensemble: bool = True,
+ include_multi_model: bool = True,
) -> None:
if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon)
if nws_data:
results["nws"] = nws_data
- ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
- if ensemble_data:
- results["ensemble"] = ensemble_data
+ if include_ensemble:
+ ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
+ if ensemble_data:
+ results["ensemble"] = ensemble_data
- multi_model_data = self.fetch_multi_model(
- lat, lon, city=city, use_fahrenheit=use_fahrenheit
- )
- if multi_model_data:
- results["multi_model"] = multi_model_data
+ if include_multi_model:
+ multi_model_data = self.fetch_multi_model(
+ lat, lon, city=city, use_fahrenheit=use_fahrenheit
+ )
+ if multi_model_data:
+ results["multi_model"] = multi_model_data
def fetch_all_sources(
self,
@@ -877,6 +911,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lon: float = None,
country: str = None,
force_refresh: bool = False,
+ include_taf: bool = True,
+ include_nearby: bool = True,
+ include_ensemble: bool = True,
+ include_multi_model: bool = True,
) -> Dict:
"""
Fetch weather data from all available sources
@@ -910,23 +948,34 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
)
if metar_data:
results["metar"] = metar_data
- if supports_aviationweather and city_lower != "hong kong":
+ if include_taf and supports_aviationweather and city_lower != "hong kong":
taf_data = self.fetch_taf(city, utc_offset=utc_offset)
if taf_data:
results["taf"] = taf_data
- self._attach_turkish_mgm_data(results, city_lower)
- self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
- if city_lower == "warsaw":
- self._attach_warsaw_official_nearby(results, use_fahrenheit)
- self._attach_global_nearby_cluster(
- results, city_lower, use_fahrenheit
+ self._attach_turkish_mgm_data(
+ results,
+ city_lower,
+ include_nearby=include_nearby,
)
+ if include_nearby:
+ self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
+ if city_lower == "warsaw":
+ self._attach_warsaw_official_nearby(results, use_fahrenheit)
+ self._attach_global_nearby_cluster(
+ results, city_lower, use_fahrenheit
+ )
self._attach_nws_and_models(
- results, city, lat, lon, use_fahrenheit
+ results,
+ city,
+ lat,
+ lon,
+ use_fahrenheit,
+ include_ensemble=include_ensemble,
+ include_multi_model=include_multi_model,
)
else:
fallback_utc_offset = int(
@@ -940,30 +989,41 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
)
if metar_data:
results["metar"] = metar_data
- if supports_aviationweather and city_lower != "hong kong":
+ if include_taf and supports_aviationweather and city_lower != "hong kong":
taf_data = self.fetch_taf(city, utc_offset=fallback_utc_offset)
if taf_data:
results["taf"] = taf_data
- self._attach_turkish_mgm_data(results, city_lower)
- self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
- self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
- if city_lower == "warsaw":
- self._attach_warsaw_official_nearby(results, use_fahrenheit)
- self._attach_global_nearby_cluster(
- results, city_lower, use_fahrenheit
+ self._attach_turkish_mgm_data(
+ results,
+ city_lower,
+ include_nearby=include_nearby,
)
+ if include_nearby:
+ self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_korea_official_nearby(results, city_lower, use_fahrenheit)
+ self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
+ if city_lower == "warsaw":
+ self._attach_warsaw_official_nearby(results, use_fahrenheit)
+ self._attach_global_nearby_cluster(
+ results, city_lower, use_fahrenheit
+ )
self._attach_nws_and_models(
- results, city, lat, lon, use_fahrenheit
+ results,
+ city,
+ lat,
+ lon,
+ use_fahrenheit,
+ include_ensemble=include_ensemble,
+ include_multi_model=include_multi_model,
)
else:
if supports_aviationweather:
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
if metar_data:
results["metar"] = metar_data
- if supports_aviationweather and city_lower != "hong kong":
+ if include_taf and supports_aviationweather and city_lower != "hong kong":
taf_data = self.fetch_taf(city)
if taf_data:
results["taf"] = taf_data
diff --git a/web/analysis_service.py b/web/analysis_service.py
index 31f6cf94..d4fd0a58 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -6,6 +6,7 @@ import os
import re
import time as _time
import threading
+from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
@@ -28,7 +29,7 @@ from web.core import (
from src.analysis.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.country_networks import build_country_network_snapshot
-from src.data_collection.city_registry import ALIASES
+from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.models.lgbm_daily_high import predict_lgbm_daily_high
TURKISH_MGM_CITIES = {"ankara", "istanbul"}
@@ -42,6 +43,8 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = {
"last_cache_miss_at": None,
"last_city": None,
}
+_SUMMARY_CACHE_LOCK = threading.Lock()
+_SUMMARY_CACHE: Dict[str, Dict[str, Any]] = {}
_GROQ_COMMENTARY_CACHE_LOCK = threading.Lock()
_GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {}
_GROQ_COMMENTARY_CACHE_TTL_SEC = int(
@@ -77,6 +80,26 @@ def get_analysis_cache_stats() -> Dict[str, Any]:
return stats
+def _analysis_ttl_for_city(city: str) -> int:
+ return CACHE_TTL_ANKARA if city.lower() in TURKISH_MGM_CITIES else CACHE_TTL
+
+
+def _get_cached_summary(city: str, ttl: int) -> Optional[Dict[str, Any]]:
+ now_ts = _time.time()
+ with _SUMMARY_CACHE_LOCK:
+ cached = _SUMMARY_CACHE.get(city)
+ if cached and now_ts - float(cached.get("t", 0)) < ttl:
+ payload = cached.get("d")
+ if isinstance(payload, dict):
+ return dict(payload)
+ return None
+
+
+def _set_cached_summary(city: str, payload: Dict[str, Any]) -> None:
+ with _SUMMARY_CACHE_LOCK:
+ _SUMMARY_CACHE[city] = {"t": _time.time(), "d": dict(payload)}
+
+
def _groq_commentary_enabled() -> bool:
enabled = str(
os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false")
@@ -1027,6 +1050,7 @@ def _analyze(
city: str,
force_refresh: bool = False,
include_llm_commentary: bool = False,
+ detail_mode: str = "full",
) -> Dict[str, Any]:
"""Fetch, analyse, and return structured weather data for one city."""
# Check cache
@@ -1057,11 +1081,17 @@ def _analyze(
)
# ── 1. Fetch raw data ──
+ is_panel_mode = str(detail_mode or "full").strip().lower() == "panel"
+
raw = _weather.fetch_all_sources(
city,
lat=lat,
lon=lon,
force_refresh=force_refresh,
+ include_taf=not is_panel_mode,
+ include_nearby=not is_panel_mode,
+ include_ensemble=not is_panel_mode,
+ include_multi_model=not is_panel_mode,
)
om = raw.get("open-meteo", {})
metar = raw.get("metar", {})
@@ -1083,7 +1113,11 @@ def _analyze(
if not isinstance(mm, dict):
mm = {}
risk = CITY_RISK_PROFILES.get(city, {})
- network_snapshot = build_country_network_snapshot(city, raw)
+ network_snapshot = (
+ build_country_network_snapshot(city, raw)
+ if not is_panel_mode
+ else {}
+ )
# ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ──
mc = metar.get("current", {}) if metar else {}
@@ -1541,20 +1575,28 @@ def _analyze(
h_boundary_layer_height[i] if i < len(h_boundary_layer_height) else None
)
- vertical_profile_signal = _build_vertical_profile_signal(
- next_48h_hourly,
- local_date_str,
- local_hour,
- first_peak_h,
- last_peak_h,
+ vertical_profile_signal = (
+ _build_vertical_profile_signal(
+ next_48h_hourly,
+ local_date_str,
+ local_hour,
+ first_peak_h,
+ last_peak_h,
+ )
+ if not is_panel_mode
+ else {}
)
- taf_signal = _build_taf_signal(
- taf if isinstance(taf, dict) else {},
- city,
- local_date_str,
- int(utc_offset or 0),
- first_peak_h,
- last_peak_h,
+ taf_signal = (
+ _build_taf_signal(
+ taf if isinstance(taf, dict) else {},
+ city,
+ local_date_str,
+ int(utc_offset or 0),
+ first_peak_h,
+ last_peak_h,
+ )
+ if not is_panel_mode
+ else {"available": False}
)
# ── 13. Cloud description (METAR primary, MGM fallback) ──
@@ -1703,6 +1745,7 @@ def _analyze(
# ── Assemble result ──
city_meta = CITIES.get(city, {}) or {}
result = {
+ "detail_depth": "panel" if is_panel_mode else "full",
"name": city,
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
"lat": lat,
@@ -1844,6 +1887,273 @@ def _normalize_city_or_404(name: str) -> str:
return city
+def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
+ ttl = _analysis_ttl_for_city(city)
+
+ if not force_refresh:
+ cached_detail = _cache.get(city)
+ if cached_detail and _time.time() - cached_detail["t"] < ttl:
+ return cached_detail["d"]
+ cached_summary = _get_cached_summary(city, ttl)
+ if cached_summary:
+ return cached_summary
+
+ info = CITIES[city]
+ lat, lon, is_f = info["lat"], info["lon"], info["f"]
+ sym = "°F" if is_f else "°C"
+ settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
+ settlement_source_label = SETTLEMENT_SOURCE_LABELS.get(
+ settlement_source,
+ settlement_source.upper(),
+ )
+
+ if force_refresh:
+ try:
+ _weather._evict_city_caches( # type: ignore[attr-defined]
+ city=city,
+ lat=lat,
+ lon=lon,
+ use_fahrenheit=is_f,
+ )
+ except Exception:
+ pass
+
+ default_utc_offset = int(info.get("tz", 0) or 0)
+
+ def _safe_call(fn):
+ try:
+ return fn()
+ except Exception:
+ return None
+
+ jobs = {
+ "settlement_current": lambda: _weather.fetch_settlement_current(city) or {},
+ "open_meteo": lambda: _weather.fetch_from_open_meteo(lat, lon, use_fahrenheit=is_f) or {},
+ }
+ if _weather._supports_aviationweather(city): # type: ignore[attr-defined]
+ jobs["metar"] = lambda: _weather.fetch_metar(
+ city,
+ use_fahrenheit=is_f,
+ utc_offset=default_utc_offset,
+ ) or {}
+ if city in TURKISH_MGM_CITIES:
+ istno, _province = _weather.TURKISH_PROVINCES.get(city, (None, None)) # type: ignore[attr-defined]
+ if istno:
+ jobs["mgm"] = lambda istno=istno: _weather.fetch_from_mgm(str(istno)) or {}
+ if is_f:
+ jobs["nws"] = lambda: _weather.fetch_nws(lat, lon) or {}
+ if settlement_source == "hko":
+ jobs["hko_forecast"] = lambda: _weather.fetch_hko_forecast()
+
+ fetched: Dict[str, Any] = {}
+ with ThreadPoolExecutor(max_workers=min(6, len(jobs))) as executor:
+ future_map = {
+ executor.submit(_safe_call, fn): key
+ for key, fn in jobs.items()
+ }
+ for future, key in [(future, key) for future, key in future_map.items()]:
+ fetched[key] = future.result()
+
+ settlement_current = fetched.get("settlement_current") or {}
+ open_meteo = fetched.get("open_meteo") or {}
+ utc_offset = open_meteo.get("utc_offset")
+ if utc_offset is None:
+ utc_offset = default_utc_offset
+ try:
+ utc_offset = int(utc_offset or 0)
+ except Exception:
+ utc_offset = default_utc_offset
+ metar = fetched.get("metar") or {}
+ mgm = fetched.get("mgm") or {}
+ nws = fetched.get("nws") or {}
+ hko_forecast = fetched.get("hko_forecast")
+
+ sc_cur = settlement_current.get("current") or {}
+ mc = metar.get("current") or {}
+ mg_cur = mgm.get("current") or {}
+ use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
+ primary_current = sc_cur if use_settlement_current else mc
+
+ cur_temp = _sf(primary_current.get("temp"))
+ if cur_temp is None:
+ cur_temp = _sf(mc.get("temp"))
+ if cur_temp is None:
+ cur_temp = _sf(mg_cur.get("temp"))
+
+ max_so_far = _sf(primary_current.get("max_temp_so_far"))
+ if max_so_far is None:
+ max_so_far = _sf(mc.get("max_temp_so_far"))
+ if max_so_far is None:
+ max_so_far = _sf(mg_cur.get("mgm_max_temp"))
+
+ max_temp_time = 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:
+ mgm_time = str(mg_cur.get("time") or "")
+ if " " in mgm_time:
+ max_temp_time = mgm_time.split(" ")[1][:5]
+
+ raw_settlement_max = max_so_far
+ wu_settle = (
+ apply_city_settlement(city.lower(), raw_settlement_max)
+ if raw_settlement_max is not None
+ else None
+ )
+ display_settlement_max = (
+ wu_settle
+ if settlement_source == "wunderground" and wu_settle is not None
+ else raw_settlement_max
+ )
+
+ obs_time_str = ""
+ obs_age_min = None
+ obs_t = ""
+ if use_settlement_current:
+ obs_t = str(settlement_current.get("observation_time") or "").strip()
+ if not obs_t:
+ obs_t = str(metar.get("observation_time") or "").strip()
+ if obs_t and "T" in obs_t:
+ try:
+ dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00"))
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset)))
+ obs_time_str = local_dt.strftime("%H:%M")
+ obs_age_min = int(
+ (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60
+ )
+ except Exception:
+ obs_time_str = str(obs_t)[:16]
+
+ om_daily = (open_meteo.get("daily") or {}) if isinstance(open_meteo, dict) else {}
+ om_hourly = (open_meteo.get("hourly") or {}) if isinstance(open_meteo, dict) else {}
+ maxtemps = om_daily.get("temperature_2m_max", [])[:5]
+ om_today = _sf(maxtemps[0]) if maxtemps else None
+ nws_high = _sf((nws or {}).get("today_high")) if isinstance(nws, dict) else None
+ mgm_high = _sf((mgm or {}).get("today_high")) if isinstance(mgm, dict) else None
+
+ if om_today is None:
+ fallback_high = (
+ nws_high
+ if nws_high is not None
+ else mgm_high
+ if mgm_high is not None
+ else max_so_far
+ if max_so_far is not None
+ else cur_temp
+ )
+ if fallback_high is not None:
+ om_today = float(fallback_high)
+
+ current_forecasts: Dict[str, float] = {}
+ if om_today is not None:
+ current_forecasts["Open-Meteo"] = om_today
+ if nws_high is not None:
+ current_forecasts["NWS"] = nws_high
+ if mgm_high is not None:
+ current_forecasts["MGM"] = mgm_high
+ if hko_forecast is not None:
+ current_forecasts["HKO"] = _sf(hko_forecast)
+ current_forecasts = {
+ model_name: value
+ for model_name, value in current_forecasts.items()
+ if value is not None and not _is_excluded_model_name(model_name)
+ }
+
+ deb_val = None
+ if current_forecasts:
+ blended, _weights_info = calculate_dynamic_weights(city, current_forecasts)
+ if blended is not None:
+ deb_val = blended
+ if deb_val is None:
+ deb_val = om_today
+
+ local_time_full = (open_meteo.get("current") or {}).get("local_time", "")
+ now_utc = datetime.now(timezone.utc)
+ local_now = now_utc + timedelta(seconds=utc_offset)
+ local_date_str = local_now.strftime("%Y-%m-%d")
+ local_hour = local_now.hour
+ local_minute = local_now.minute
+ try:
+ if local_time_full:
+ local_date_str = str(local_time_full).split(" ")[0]
+ tp = str(local_time_full).split(" ")[1].split(":")
+ local_hour = int(tp[0])
+ local_minute = int(tp[1]) if len(tp) > 1 else 0
+ except Exception:
+ pass
+ local_time_str = f"{local_hour:02d}:{local_minute:02d}"
+ local_hour_frac = local_hour + local_minute / 60.0
+
+ settlement_today_obs = []
+ if use_settlement_current:
+ explicit_obs = settlement_current.get("today_obs") or []
+ for item in explicit_obs:
+ if isinstance(item, dict):
+ raw_time = str(item.get("time") or "").strip()
+ raw_temp = _sf(item.get("temp"))
+ elif isinstance(item, (list, tuple)) and len(item) >= 2:
+ raw_time = str(item[0] or "").strip()
+ raw_temp = _sf(item[1])
+ else:
+ continue
+ if raw_time and raw_temp is not None:
+ settlement_today_obs.append({"time": raw_time, "temp": raw_temp})
+ if not settlement_today_obs and obs_time_str and cur_temp is not None:
+ settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp})
+ if max_temp_time and max_so_far is not None and str(max_temp_time) != str(obs_time_str):
+ settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far})
+
+ metar_today_obs_payload = [
+ {"time": obs_time, "temp": obs_temp}
+ for obs_time, obs_temp in ((metar.get("today_obs") or []) if isinstance(metar, dict) else [])
+ ]
+
+ deviation_monitor = _build_deviation_monitor(
+ current_temp=cur_temp,
+ deb_prediction=deb_val,
+ om_today=om_today,
+ hourly_times=om_hourly.get("time", []) if isinstance(om_hourly, dict) else [],
+ hourly_temps=om_hourly.get("temperature_2m", []) if isinstance(om_hourly, dict) else [],
+ local_date=local_date_str,
+ local_hour_frac=local_hour_frac,
+ observation_points=(
+ settlement_today_obs if settlement_today_obs else metar_today_obs_payload
+ ),
+ )
+
+ risk = CITY_RISK_PROFILES.get(city, {})
+ city_meta = CITY_REGISTRY.get(city, {}) or {}
+ result = {
+ "name": city,
+ "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
+ "temp_symbol": sym,
+ "local_time": local_time_str,
+ "local_date": local_date_str,
+ "risk": {
+ "level": risk.get("risk_level", "low"),
+ "warning": risk.get("warning", ""),
+ "icao": risk.get("icao", ""),
+ },
+ "current": {
+ "temp": _sf(cur_temp),
+ "max_so_far": _sf(display_settlement_max),
+ "max_temp_time": max_temp_time,
+ "wu_settlement": _sf(wu_settle),
+ "settlement_source": settlement_source,
+ "settlement_source_label": settlement_source_label,
+ "obs_time": obs_time_str or None,
+ "obs_age_min": obs_age_min,
+ },
+ "deb": {"prediction": _sf(deb_val)},
+ "deviation_monitor": deviation_monitor or {},
+ "updated_at": datetime.now(timezone.utc).isoformat(),
+ }
+ _set_cached_summary(city, result)
+ return result
+
+
def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
return {
"name": data.get("name"),
diff --git a/web/routes.py b/web/routes.py
index 1f534a9c..41023ea9 100644
--- a/web/routes.py
+++ b/web/routes.py
@@ -19,6 +19,7 @@ from src.data_collection.city_registry import ALIASES
from src.utils.metrics import export_prometheus_metrics
from web.analysis_service import (
_analyze,
+ _analyze_summary,
_build_city_detail_payload,
_build_city_summary_payload,
)
@@ -425,10 +426,17 @@ async def list_cities(request: Request):
@router.get("/api/city/{name}")
-async def city_detail(request: Request, name: str, force_refresh: bool = False):
+async def city_detail(
+ request: Request,
+ name: str,
+ force_refresh: bool = False,
+ depth: str = "panel",
+):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
- return await run_in_threadpool(_analyze, city, force_refresh)
+ normalized_depth = str(depth or "panel").strip().lower()
+ detail_mode = "full" if normalized_depth == "full" else "panel"
+ return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
@router.get("/api/history/{name}")
@@ -1021,7 +1029,7 @@ async def payment_reconcile_latest(request: Request):
@router.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False):
city = _normalize_city_or_404(name)
- data = await run_in_threadpool(_analyze, city, force_refresh, False)
+ data = await run_in_threadpool(_analyze_summary, city, force_refresh)
return await run_in_threadpool(_build_city_summary_payload, data)