feat: implement METAR data collection service and dashboard infrastructure

This commit is contained in:
2569718930@qq.com
2026-04-11 20:11:01 +08:00
parent 69168d2fdf
commit 77b4d7b341
14 changed files with 660 additions and 148 deletions
+7 -1
View File
@@ -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
+27
View File
@@ -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}
+2 -1
View File
@@ -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, {
@@ -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 (
<div className={styles.root}>
@@ -84,7 +93,7 @@ function DashboardScreen() {
<HeaderBar />
<CitySidebar />
<DetailPanel />
{store.loadingState.cityDetail && activeCityName ? (
{showCitySyncToast ? (
<div className="city-loading-toast" role="status" aria-live="polite">
<span className="city-loading-dot" aria-hidden="true" />
<span className="city-loading-copy">
+115 -53
View File
@@ -30,10 +30,14 @@ interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
ensureCityDetail: (
cityName: string,
force?: boolean,
depth?: "panel" | "full",
) => Promise<CityDetail>;
futureModalDate: string | null;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
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<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(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);
+12 -3
View File
@@ -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<T>(url: string): Promise<T> {
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<CityDetail>(
`/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<CityDetail>(
+1
View File
@@ -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;
+8 -9
View File
@@ -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",
+10 -2
View File
@@ -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 []
@@ -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()
+28 -16
View File
@@ -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
+101 -41
View File
@@ -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
+325 -15
View File
@@ -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"),
+11 -3
View File
@@ -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)