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_MULTI_MODEL_CACHE_VERSION=v2
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900 OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
OPEN_METEO_RATE_CACHE_TTL_SEC=3600 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 METAR_CACHE_TTL_SEC=600
METEOBLUE_CACHE_TTL_SEC=7200 METEOBLUE_CACHE_TTL_SEC=7200
POLYWEATHER_LGBM_ENABLED=false 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-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": "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": "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 { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; 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 { try {
const auth = await buildBackendRequestHeaders(req, { const auth = await buildBackendRequestHeaders(req, {
@@ -45,8 +45,12 @@ const FutureForecastModal = dynamic(
function DashboardScreen() { function DashboardScreen() {
const store = useDashboardStore(); const store = useDashboardStore();
const { t } = useI18n(); const { t } = useI18n();
const activeSummary = store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null;
const activeCityName = const activeCityName =
store.selectedDetail?.display_name || store.selectedDetail?.display_name ||
activeSummary?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)?.display_name || store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
store.selectedCity || store.selectedCity ||
""; "";
@@ -77,6 +81,11 @@ function DashboardScreen() {
const showLoading = const showLoading =
store.loadingState.cities || store.loadingState.cities ||
store.loadingState.refresh; store.loadingState.refresh;
const showCitySyncToast =
store.loadingState.cityDetail &&
activeCityName &&
!store.selectedDetail &&
!activeSummary;
return ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -84,7 +93,7 @@ function DashboardScreen() {
<HeaderBar /> <HeaderBar />
<CitySidebar /> <CitySidebar />
<DetailPanel /> <DetailPanel />
{store.loadingState.cityDetail && activeCityName ? ( {showCitySyncToast ? (
<div className="city-loading-toast" role="status" aria-live="polite"> <div className="city-loading-toast" role="status" aria-live="polite">
<span className="city-loading-dot" aria-hidden="true" /> <span className="city-loading-dot" aria-hidden="true" />
<span className="city-loading-copy"> <span className="city-loading-copy">
+115 -53
View File
@@ -30,10 +30,14 @@ interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void; closeFutureModal: () => void;
closeHistory: () => void; closeHistory: () => void;
closePanel: () => void; closePanel: () => void;
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>; ensureCityDetail: (
cityName: string,
force?: boolean,
depth?: "panel" | "full",
) => Promise<CityDetail>;
futureModalDate: string | null; futureModalDate: string | null;
loadCities: () => Promise<void>; loadCities: () => Promise<void>;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void; openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>; openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>; openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => 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 BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
const EAGER_CITY_SUMMARIES_ENABLED = const EAGER_CITY_SUMMARIES_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES === "true"; process.env.NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES === "true";
type CityDetailDepth = "panel" | "full";
function countAvailableModels( function countAvailableModels(
detail?: CityDetail | null, 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({ export function DashboardStoreProvider({
children, children,
}: { }: {
@@ -171,6 +189,7 @@ export function DashboardStoreProvider({
const hydratedProCacheRef = useRef(false); const hydratedProCacheRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({}); const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>({}); const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const selectedDetail = const selectedDetail =
selectedCity && proAccess.subscriptionActive selectedCity && proAccess.subscriptionActive
? cityDetailsByName[selectedCity] || null ? cityDetailsByName[selectedCity] || null
@@ -210,6 +229,10 @@ export function DashboardStoreProvider({
citySummariesRef.current = citySummariesByName; citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]); }, [citySummariesByName]);
useEffect(() => {
selectedCityRef.current = selectedCity;
}, [selectedCity]);
useEffect(() => { useEffect(() => {
proAccessRef.current = proAccess; proAccessRef.current = proAccess;
}, [proAccess]); }, [proAccess]);
@@ -273,6 +296,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, { const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false, force: false,
depth: normalizeDetailDepth(cached),
}); });
const detail = latestDetail; const detail = latestDetail;
@@ -295,13 +319,19 @@ export function DashboardStoreProvider({
.catch(() => {}); .catch(() => {});
}; };
const ensureCityDetail = async (cityName: string, force = false) => { const ensureCityDetail = async (
cityName: string,
force = false,
depth: CityDetailDepth = "panel",
) => {
const cached = cityDetailsByName[cityName]; const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName];
const hasRequestedDepth = detailSatisfiesDepth(cached, depth);
const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date); const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date);
if ( if (
!force && !force &&
cached && cached &&
hasRequestedDepth &&
!cachedIsSparse && !cachedIsSparse &&
dashboardClient.isCityDetailFresh(cachedMeta) dashboardClient.isCityDetailFresh(cachedMeta)
) { ) {
@@ -309,7 +339,7 @@ export function DashboardStoreProvider({
return cached; return cached;
} }
if (!force && cached) { if (!force && cached && hasRequestedDepth) {
try { try {
const summary = await dashboardClient.getCitySummary(cityName); const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary); const revision = getCityRevision(summary);
@@ -317,6 +347,7 @@ export function DashboardStoreProvider({
if (cachedIsSparse) { if (cachedIsSparse) {
const latestDetail = await dashboardClient.getCityDetail(cityName, { const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: true, force: true,
depth,
}); });
const detail = latestDetail; const detail = latestDetail;
setCityDetailsByName((current) => ({ setCityDetailsByName((current) => ({
@@ -352,6 +383,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, { const latestDetail = await dashboardClient.getCityDetail(cityName, {
force, force,
depth,
}); });
const detail = latestDetail; const detail = latestDetail;
setCityDetailsByName((current) => ({ setCityDetailsByName((current) => ({
@@ -381,7 +413,7 @@ export function DashboardStoreProvider({
let cancelled = false; let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true })); setLoadingState((current) => ({ ...current, cityDetail: true }));
void ensureCityDetail(selectedCity, false) void ensureCityDetail(selectedCity, false, "panel")
.then((detail) => { .then((detail) => {
if (cancelled) return; if (cancelled) return;
setSelectedForecastDate(detail.local_date); setSelectedForecastDate(detail.local_date);
@@ -577,26 +609,35 @@ export function DashboardStoreProvider({
}; };
}, [cities]); }, [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) => { const selectCity = async (cityName: string) => {
setSelectedCity(cityName); setSelectedCity(cityName);
setIsPanelOpen(true); setIsPanelOpen(true);
setSelectedForecastDate(null); setSelectedForecastDate(null);
setFutureModalDate(null); setFutureModalDate(null);
const summaryPromise = !citySummariesRef.current[cityName]
? ensureCitySummary(cityName).catch(() => null)
: Promise.resolve(citySummariesRef.current[cityName]);
if (proAccessRef.current.loading) { if (proAccessRef.current.loading) {
setLoadingState((current) => ({ ...current, cityDetail: true })); setLoadingState((current) => ({ ...current, cityDetail: true }));
if (!citySummariesRef.current[cityName]) { try {
try { await summaryPromise;
const summary = await dashboardClient.getCitySummary(cityName); } catch {
setCitySummariesByName((current) => ({ } finally {
...current,
[cityName]: summary,
}));
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
} else {
setLoadingState((current) => ({ ...current, cityDetail: false })); setLoadingState((current) => ({ ...current, cityDetail: false }));
} }
return; return;
@@ -605,18 +646,10 @@ export function DashboardStoreProvider({
const access = proAccessRef.current; const access = proAccessRef.current;
if (!access.authenticated || !access.subscriptionActive) { if (!access.authenticated || !access.subscriptionActive) {
setLoadingState((current) => ({ ...current, cityDetail: true })); setLoadingState((current) => ({ ...current, cityDetail: true }));
if (!citySummariesRef.current[cityName]) { try {
try { await summaryPromise;
const summary = await dashboardClient.getCitySummary(cityName); } catch {
setCitySummariesByName((current) => ({ } finally {
...current,
[cityName]: summary,
}));
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
} else {
setLoadingState((current) => ({ ...current, cityDetail: false })); setLoadingState((current) => ({ ...current, cityDetail: false }));
} }
return; return;
@@ -629,20 +662,28 @@ export function DashboardStoreProvider({
); );
setLoadingState((current) => ({ ...current, cityDetail: true })); setLoadingState((current) => ({ ...current, cityDetail: true }));
try { try {
const detail = await ensureCityDetail(cityName, needsDetailRefresh); await summaryPromise;
setSelectedForecastDate(detail.local_date); } catch {
if (access.authenticated && access.subscriptionActive) {
// 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(
cityName,
false,
null,
detail.local_date,
).catch(() => {});
}
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
} }
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(() => { useEffect(() => {
@@ -678,7 +719,7 @@ export function DashboardStoreProvider({
if (!selectedCity) return; if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true })); setLoadingState((current) => ({ ...current, refresh: true }));
try { try {
const detail = await ensureCityDetail(selectedCity, true); const detail = await ensureCityDetail(selectedCity, true, "panel");
setSelectedForecastDate(detail.local_date); setSelectedForecastDate(detail.local_date);
} finally { } finally {
setLoadingState((current) => ({ ...current, refresh: false })); setLoadingState((current) => ({ ...current, refresh: false }));
@@ -696,6 +737,7 @@ export function DashboardStoreProvider({
if (access.authenticated && access.subscriptionActive) { if (access.authenticated && access.subscriptionActive) {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, { const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true, force: true,
depth: "panel",
}); });
const detail = latestDetail; const detail = latestDetail;
setCityDetailsByName({ [selectedCity]: detail }); setCityDetailsByName({ [selectedCity]: detail });
@@ -779,16 +821,31 @@ export function DashboardStoreProvider({
loadCities, loadCities,
loadingState, loadingState,
proAccess, proAccess,
openFutureModal: (dateStr: string, forceRefresh = false) => { openFutureModal: async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current(); mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity || !proAccess.subscriptionActive) return; if (!selectedCity || !proAccess.subscriptionActive) return;
const cachedDetail = cityDetailsByName[selectedCity]; const cachedDetail = cityDetailsByName[selectedCity];
const needsDetailRefresh = const hasFullCachedDetail =
!forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr); detailSatisfiesDepth(cachedDetail, "full") &&
if (needsDetailRefresh) { !hasSparseDetailCoverage(cachedDetail, dateStr);
void ensureCityDetail(selectedCity, true).catch(() => {});
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); const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true })); setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan( void ensureCityMarketScan(
@@ -810,25 +867,30 @@ export function DashboardStoreProvider({
mapStopMotionRef.current(); mapStopMotionRef.current();
const cachedDetail = cityDetailsByName[selectedCity]; 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); setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date); setFutureModalDate(cachedDetail.local_date);
} }
if (!proAccess.subscriptionActive) return; if (!proAccess.subscriptionActive) return;
const needsDetailRefresh = const needsDetailRefresh =
!forceRefresh && forceRefresh ||
!detailSatisfiesDepth(cachedDetail, "full") ||
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
setLoadingState((current) => ({ setLoadingState((current) => ({
...current, ...current,
refresh: !cachedDetail?.local_date, refresh: needsDetailRefresh,
marketScan: true, marketScan: true,
})); }));
try { try {
const detail = await ensureCityDetail( const detail = await ensureCityDetail(
selectedCity, selectedCity,
Boolean(forceRefresh || needsDetailRefresh), needsDetailRefresh,
"full",
); );
setSelectedForecastDate(detail.local_date); setSelectedForecastDate(detail.local_date);
setFutureModalDate(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, "-")); 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> { async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
@@ -155,17 +159,21 @@ export const dashboardClient = {
return request; 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 force = options?.force ?? false;
const depth = normalizeDetailDepth(options?.depth);
if (!force) { if (!force) {
const requestKey = `${cityName}::cached`; const requestKey = `${cityName}::${depth}::cached`;
const existing = pendingCityDetailRequests.get(requestKey); const existing = pendingCityDetailRequests.get(requestKey);
if (existing) { if (existing) {
return existing; return existing;
} }
const request = fetchJson<CityDetail>( const request = fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=false`, `/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
).finally(() => { ).finally(() => {
pendingCityDetailRequests.delete(requestKey); pendingCityDetailRequests.delete(requestKey);
}); });
@@ -176,6 +184,7 @@ export const dashboardClient = {
const params = new URLSearchParams({ const params = new URLSearchParams({
force_refresh: "true", force_refresh: "true",
depth,
_ts: String(Date.now()), _ts: String(Date.now()),
}); });
return fetchJson<CityDetail>( return fetchJson<CityDetail>(
+1
View File
@@ -327,6 +327,7 @@ export interface AiAnalysisStructured {
export interface CityDetail { export interface CityDetail {
name: string; name: string;
display_name: string; display_name: string;
detail_depth?: "panel" | "full";
lat: number; lat: number;
lon: number; lon: number;
temp_symbol: string; temp_symbol: string;
+8 -9
View File
@@ -149,21 +149,20 @@ CITY_REGISTRY = {
}, },
"taipei": { "taipei": {
"name": "Taipei", "name": "Taipei",
"lat": 25.0670, "lat": 25.0377,
"lon": 121.5525, "lon": 121.5149,
"icao": "RCSS", "icao": "RCSS",
"settlement_source": "wunderground", "settlement_source": "cwa",
"settlement_station_code": "RCSS", "settlement_station_code": "466920",
"settlement_station_label": "Taipei Songshan Airport Station", "settlement_station_label": "中央气象署台北站",
"settlement_url": "https://www.wunderground.com/history/daily/tw/taipei/RCSS",
"tz_offset": 28800, "tz_offset": 28800,
"use_fahrenheit": False, "use_fahrenheit": False,
"is_major": True, "is_major": True,
"risk_level": "low", "risk_level": "low",
"risk_emoji": "🟢", "risk_emoji": "🟢",
"airport_name": "台北松山机场", "airport_name": "中央气象署台北站",
"distance_km": 4.1, "distance_km": 0.0,
"warning": "市场现按 Wunderground 台北松山机场站整度°C口径结算;以历史页当日最终完成后的最高整度摄氏值为准", "warning": "结算按交通部中央气象署台北站口径,不应混用松山机场 METAR 作为结算主源",
}, },
"shanghai": { "shanghai": {
"name": "Shanghai", "name": "Shanghai",
+10 -2
View File
@@ -248,7 +248,11 @@ class MetarSourceMixin:
"hours": 24, "hours": 24,
"_t": int(time.time()), "_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() response.raise_for_status()
data = response.json() data = response.json()
if not data: if not data:
@@ -295,7 +299,11 @@ class MetarSourceMixin:
headers = { 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", "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: if resp.status_code != 200:
logger.warning(f"METAR cluster fetch HTTP {resp.status_code} for {icaos}") logger.warning(f"METAR cluster fetch HTTP {resp.status_code} for {icaos}")
return [] return []
@@ -208,7 +208,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -369,7 +369,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -526,7 +526,7 @@ class NwsOpenMeteoSourceMixin:
response = self._http_get( response = self._http_get(
url, url,
params=params, params=params,
timeout=self.timeout, timeout=getattr(self, "open_meteo_timeout_sec", self.timeout),
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
+28 -16
View File
@@ -4,6 +4,7 @@ import csv
import math import math
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -226,19 +227,31 @@ class SettlementSourceMixin:
try: try:
base = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather" 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) csv_urls = {
temp_csv.raise_for_status() "temp": f"{base}/latest_1min_temperature.csv",
maxmin_csv = self._http_get(f"{base}/latest_since_midnight_maxmin.csv", timeout=self.timeout) "maxmin": f"{base}/latest_since_midnight_maxmin.csv",
maxmin_csv.raise_for_status() "humidity": f"{base}/latest_1min_humidity.csv",
humidity_csv = self._http_get(f"{base}/latest_1min_humidity.csv", timeout=self.timeout) "wind": f"{base}/latest_10min_wind.csv",
humidity_csv.raise_for_status() }
wind_csv = self._http_get(f"{base}/latest_10min_wind.csv", timeout=self.timeout)
wind_csv.raise_for_status()
temp_rows = self._csv_rows(temp_csv.text) def _fetch_csv(url: str):
maxmin_rows = self._csv_rows(maxmin_csv.text) response = self._http_get(url, timeout=self.timeout)
humidity_rows = self._csv_rows(humidity_csv.text) response.raise_for_status()
wind_rows = self._csv_rows(wind_csv.text) 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) temp_row = self._pick_station_row(temp_rows, candidate_names)
maxmin_row = self._pick_station_row(maxmin_rows, candidate_names) maxmin_row = self._pick_station_row(maxmin_rows, candidate_names)
@@ -626,6 +639,8 @@ class SettlementSourceMixin:
station_name=station_name, station_name=station_name,
station_candidates=station_candidates, station_candidates=station_candidates,
) )
if settlement_source == "cwa":
return self.fetch_cwa_taipei_settlement_current()
if settlement_source == "noaa": if settlement_source == "noaa":
station_code = ( station_code = (
str(city_meta.get("settlement_station_code") or "").strip() str(city_meta.get("settlement_station_code") or "").strip()
@@ -643,8 +658,5 @@ class SettlementSourceMixin:
except Exception as exc: except Exception as exc:
logger.warning(f"Settlement source dispatch failed city={city}: {exc}") logger.warning(f"Settlement source dispatch failed city={city}: {exc}")
if normalized == "taipei": if normalized == "taipei":
return self.fetch_noaa_station_settlement_current( return self.fetch_cwa_taipei_settlement_current()
station_code="RCTP",
station_name="Taiwan Taoyuan International Airport",
)
return None return None
+101 -41
View File
@@ -113,12 +113,24 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
def __init__(self, config: dict): def __init__(self, config: dict):
self.config = config 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( 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( 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( self.session = httpx.Client(
timeout=self.timeout, timeout=self.timeout,
@@ -151,7 +163,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self._open_meteo_rl_lock = threading.Lock() self._open_meteo_rl_lock = threading.Lock()
# Open-Meteo burst control: avoid hammering API with many cities at once. # Open-Meteo burst control: avoid hammering API with many cities at once.
self._open_meteo_min_interval_sec: float = float( 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_last_call_ts: float = 0.0
self._open_meteo_call_lock = threading.Lock() self._open_meteo_call_lock = threading.Lock()
@@ -728,8 +740,18 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
hko_forecast = self.fetch_hko_forecast() hko_forecast = self.fetch_hko_forecast()
if hko_forecast: if hko_forecast:
results["hko_forecast"] = 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: if city_lower not in self.TURKISH_PROVINCES:
return return
istno, province = self.TURKISH_PROVINCES[city_lower] istno, province = self.TURKISH_PROVINCES[city_lower]
@@ -737,15 +759,22 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
if not mgm_data: if not mgm_data:
return return
results["mgm"] = mgm_data results["mgm"] = mgm_data
results["nearby_source"] = "mgm" if include_nearby:
nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno) results["nearby_source"] = "mgm"
if nearby: nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno)
results["mgm_nearby"] = nearby if nearby:
results["mgm_nearby"] = nearby
def _attach_global_nearby_cluster( def _attach_global_nearby_cluster(
self, results: Dict, city_lower: str, use_fahrenheit: bool self, results: Dict, city_lower: str, use_fahrenheit: bool
) -> None: ) -> 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 return
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower] cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
cluster_data = self.fetch_metar_nearby_cluster( cluster_data = self.fetch_metar_nearby_cluster(
@@ -854,21 +883,26 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lat: float, lat: float,
lon: float, lon: float,
use_fahrenheit: bool, use_fahrenheit: bool,
*,
include_ensemble: bool = True,
include_multi_model: bool = True,
) -> None: ) -> None:
if use_fahrenheit: if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon) nws_data = self.fetch_nws(lat, lon)
if nws_data: if nws_data:
results["nws"] = nws_data results["nws"] = nws_data
ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit) if include_ensemble:
if ensemble_data: ensemble_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
results["ensemble"] = ensemble_data if ensemble_data:
results["ensemble"] = ensemble_data
multi_model_data = self.fetch_multi_model( if include_multi_model:
lat, lon, city=city, use_fahrenheit=use_fahrenheit 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 multi_model_data:
results["multi_model"] = multi_model_data
def fetch_all_sources( def fetch_all_sources(
self, self,
@@ -877,6 +911,10 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lon: float = None, lon: float = None,
country: str = None, country: str = None,
force_refresh: bool = False, force_refresh: bool = False,
include_taf: bool = True,
include_nearby: bool = True,
include_ensemble: bool = True,
include_multi_model: bool = True,
) -> Dict: ) -> Dict:
""" """
Fetch weather data from all available sources Fetch weather data from all available sources
@@ -910,23 +948,34 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
) )
if metar_data: if metar_data:
results["metar"] = 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) taf_data = self.fetch_taf(city, utc_offset=utc_offset)
if taf_data: if taf_data:
results["taf"] = taf_data results["taf"] = taf_data
self._attach_turkish_mgm_data(results, city_lower) self._attach_turkish_mgm_data(
self._attach_china_official_nearby(results, city_lower, use_fahrenheit) results,
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) city_lower,
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit) include_nearby=include_nearby,
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
) )
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( 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: else:
fallback_utc_offset = int( fallback_utc_offset = int(
@@ -940,30 +989,41 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
) )
if metar_data: if metar_data:
results["metar"] = 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) taf_data = self.fetch_taf(city, utc_offset=fallback_utc_offset)
if taf_data: if taf_data:
results["taf"] = taf_data results["taf"] = taf_data
self._attach_turkish_mgm_data(results, city_lower) self._attach_turkish_mgm_data(
self._attach_china_official_nearby(results, city_lower, use_fahrenheit) results,
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) city_lower,
self._attach_korea_official_nearby(results, city_lower, use_fahrenheit) include_nearby=include_nearby,
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
) )
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( 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: else:
if supports_aviationweather: if supports_aviationweather:
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit) metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
if metar_data: if metar_data:
results["metar"] = 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) taf_data = self.fetch_taf(city)
if taf_data: if taf_data:
results["taf"] = taf_data results["taf"] = taf_data
+325 -15
View File
@@ -6,6 +6,7 @@ import os
import re import re
import time as _time import time as _time
import threading import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional 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.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.country_networks import build_country_network_snapshot 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 from src.models.lgbm_daily_high import predict_lgbm_daily_high
TURKISH_MGM_CITIES = {"ankara", "istanbul"} TURKISH_MGM_CITIES = {"ankara", "istanbul"}
@@ -42,6 +43,8 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = {
"last_cache_miss_at": None, "last_cache_miss_at": None,
"last_city": 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_LOCK = threading.Lock()
_GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {} _GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {}
_GROQ_COMMENTARY_CACHE_TTL_SEC = int( _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
@@ -77,6 +80,26 @@ def get_analysis_cache_stats() -> Dict[str, Any]:
return stats 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: def _groq_commentary_enabled() -> bool:
enabled = str( enabled = str(
os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false") os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false")
@@ -1027,6 +1050,7 @@ def _analyze(
city: str, city: str,
force_refresh: bool = False, force_refresh: bool = False,
include_llm_commentary: bool = False, include_llm_commentary: bool = False,
detail_mode: str = "full",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Fetch, analyse, and return structured weather data for one city.""" """Fetch, analyse, and return structured weather data for one city."""
# Check cache # Check cache
@@ -1057,11 +1081,17 @@ def _analyze(
) )
# ── 1. Fetch raw data ── # ── 1. Fetch raw data ──
is_panel_mode = str(detail_mode or "full").strip().lower() == "panel"
raw = _weather.fetch_all_sources( raw = _weather.fetch_all_sources(
city, city,
lat=lat, lat=lat,
lon=lon, lon=lon,
force_refresh=force_refresh, 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", {}) om = raw.get("open-meteo", {})
metar = raw.get("metar", {}) metar = raw.get("metar", {})
@@ -1083,7 +1113,11 @@ def _analyze(
if not isinstance(mm, dict): if not isinstance(mm, dict):
mm = {} mm = {}
risk = CITY_RISK_PROFILES.get(city, {}) 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) ── # ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ──
mc = metar.get("current", {}) if metar else {} mc = metar.get("current", {}) if metar else {}
@@ -1541,20 +1575,28 @@ def _analyze(
h_boundary_layer_height[i] if i < len(h_boundary_layer_height) else None h_boundary_layer_height[i] if i < len(h_boundary_layer_height) else None
) )
vertical_profile_signal = _build_vertical_profile_signal( vertical_profile_signal = (
next_48h_hourly, _build_vertical_profile_signal(
local_date_str, next_48h_hourly,
local_hour, local_date_str,
first_peak_h, local_hour,
last_peak_h, first_peak_h,
last_peak_h,
)
if not is_panel_mode
else {}
) )
taf_signal = _build_taf_signal( taf_signal = (
taf if isinstance(taf, dict) else {}, _build_taf_signal(
city, taf if isinstance(taf, dict) else {},
local_date_str, city,
int(utc_offset or 0), local_date_str,
first_peak_h, int(utc_offset or 0),
last_peak_h, first_peak_h,
last_peak_h,
)
if not is_panel_mode
else {"available": False}
) )
# ── 13. Cloud description (METAR primary, MGM fallback) ── # ── 13. Cloud description (METAR primary, MGM fallback) ──
@@ -1703,6 +1745,7 @@ def _analyze(
# ── Assemble result ── # ── Assemble result ──
city_meta = CITIES.get(city, {}) or {} city_meta = CITIES.get(city, {}) or {}
result = { result = {
"detail_depth": "panel" if is_panel_mode else "full",
"name": city, "name": city,
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
"lat": lat, "lat": lat,
@@ -1844,6 +1887,273 @@ def _normalize_city_or_404(name: str) -> str:
return city 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]: def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
return { return {
"name": data.get("name"), "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 src.utils.metrics import export_prometheus_metrics
from web.analysis_service import ( from web.analysis_service import (
_analyze, _analyze,
_analyze_summary,
_build_city_detail_payload, _build_city_detail_payload,
_build_city_summary_payload, _build_city_summary_payload,
) )
@@ -425,10 +426,17 @@ async def list_cities(request: Request):
@router.get("/api/city/{name}") @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) _assert_entitlement(request)
city = _normalize_city_or_404(name) 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}") @router.get("/api/history/{name}")
@@ -1021,7 +1029,7 @@ async def payment_reconcile_latest(request: Request):
@router.get("/api/city/{name}/summary") @router.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False): async def city_summary(request: Request, name: str, force_refresh: bool = False):
city = _normalize_city_or_404(name) 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) return await run_in_threadpool(_build_city_summary_payload, data)