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_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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user