feat: implement METAR data collection service and dashboard infrastructure
This commit is contained in:
+7
-1
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user