Unify runtime state in SQLite and add rollout observability

This commit is contained in:
2569718930@qq.com
2026-03-20 23:00:07 +08:00
parent 6b76290cff
commit 43749fff7c
24 changed files with 1875 additions and 15 deletions
+1
View File
@@ -17,6 +17,7 @@ OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
# Windows / macOS can usually keep the defaults. # Windows / macOS can usually keep the defaults.
UID=1000 UID=1000
GID=1000 GID=1000
POLYWEATHER_STATE_STORAGE_MODE=dual
######################################## ########################################
# 2) Telegram bot minimal # 2) Telegram bot minimal
@@ -0,0 +1,72 @@
{
"evaluation_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\evaluation_report.json",
"shadow_report_path": "E:\\web\\PolyWeather\\artifacts\\probability_calibration\\shadow_report.json",
"evaluation_report_exists": true,
"shadow_report_exists": true,
"decision": {
"decision": "hold",
"ready_for_primary": false,
"summary": "当前指标不足以切换 emos_primary,应继续保持 shadow。",
"thresholds": {
"evaluation_min_samples": 80,
"shadow_min_samples": 50,
"max_delta_mae": 0.05,
"min_delta_crps": -0.02,
"min_delta_bucket_hit_rate": 0.0,
"max_delta_bucket_brier_promote": 0.02,
"max_delta_bucket_brier_observe": 0.15
},
"evaluation": {
"sample_count": 105,
"delta_crps": -0.093663,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0
},
"shadow": {
"sample_count": 103,
"delta_mae": 0.012708,
"delta_bucket_hit_rate": 0.009709,
"delta_bucket_brier": 0.293835
},
"blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835"
],
"worst_shadow_regressions": [
{
"city": "dallas",
"samples": 4,
"delta_mae": 0.114807,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678
},
{
"city": "chicago",
"samples": 4,
"delta_mae": 0.075265,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156
},
{
"city": "seattle",
"samples": 4,
"delta_mae": 0.11262,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003
},
{
"city": "atlanta",
"samples": 4,
"delta_mae": 0.293028,
"delta_bucket_hit_rate": -0.25,
"delta_bucket_brier": 0.601425
},
{
"city": "miami",
"samples": 4,
"delta_mae": 0.241559,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.478245
}
]
}
}
+36
View File
@@ -1 +1,37 @@
{"city": "ankara", "timestamp": "2026-03-20T12:00:00+03:00", "date": "2026-03-20", "temp_symbol": "°C", "raw_mu": 15.2, "raw_sigma": 1.2, "deb_prediction": 15.4, "ensemble": {"p10": 14.8, "median": 15.8, "p90": 17.9}, "multi_model": {"ECMWF": 15.8, "GFS": 14.1, "ICON": 15.9}, "max_so_far": 15.0, "peak_status": "before", "prob_snapshot": [{"v": 15, "p": 0.552}, {"v": 16, "p": 0.377}], "shadow_prob_snapshot": [{"v": 15, "p": 0.324}, {"v": 16, "p": 0.238}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320130245", "calibration_source": "artifacts/probability_calibration/default.json", "calibrated_mu": 15.1, "calibrated_sigma": 1.25} {"city": "ankara", "timestamp": "2026-03-20T12:00:00+03:00", "date": "2026-03-20", "temp_symbol": "°C", "raw_mu": 15.2, "raw_sigma": 1.2, "deb_prediction": 15.4, "ensemble": {"p10": 14.8, "median": 15.8, "p90": 17.9}, "multi_model": {"ECMWF": 15.8, "GFS": 14.1, "ICON": 15.9}, "max_so_far": 15.0, "peak_status": "before", "prob_snapshot": [{"v": 15, "p": 0.552}, {"v": 16, "p": 0.377}], "shadow_prob_snapshot": [{"v": 15, "p": 0.324}, {"v": 16, "p": 0.238}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320130245", "calibration_source": "artifacts/probability_calibration/default.json", "calibrated_mu": 15.1, "calibrated_sigma": 1.25}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
@@ -0,0 +1,53 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def main():
parser = argparse.ArgumentParser(description='Export runtime state from SQLite back to legacy JSON files.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
daily = DailyRecordRepository().load_all()
telegram = TelegramAlertStateRepository().load_state()
snapshots = ProbabilitySnapshotRepository().load_all_rows()
open_meteo = OpenMeteoCacheRepository().load_payload(args.open_meteo_max_age)
os.makedirs(os.path.dirname(os.path.abspath(args.daily_records)), exist_ok=True)
with open(args.daily_records, 'w', encoding='utf-8') as fh:
json.dump(daily, fh, ensure_ascii=False, indent=2)
with open(args.telegram_state, 'w', encoding='utf-8') as fh:
json.dump(telegram, fh, ensure_ascii=False, indent=2)
with open(args.snapshots, 'w', encoding='utf-8') as fh:
for row in snapshots:
fh.write(json.dumps(row, ensure_ascii=False) + '\n')
with open(args.open_meteo_cache, 'w', encoding='utf-8') as fh:
json.dump(open_meteo, fh, ensure_ascii=False)
print(json.dumps({
'daily_records_exported': sum(len(v) for v in daily.values()),
'telegram_state_exported': len((telegram.get('last_by_city') or {})) + len((telegram.get('by_signature') or {})),
'snapshots_exported': len(snapshots),
'open_meteo_cache_exported': sum(len((open_meteo.get(k) or {})) for k in ('forecast', 'ensemble', 'multi_model')),
}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+7
View File
@@ -14,6 +14,11 @@ from src.analysis.probability_calibration import ( # noqa: E402
fit_calibration, fit_calibration,
) )
from src.analysis.deb_algorithm import load_history # noqa: E402 from src.analysis.deb_algorithm import load_history # noqa: E402
from src.database.runtime_state import ( # noqa: E402
ProbabilitySnapshotRepository,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
def _sf(value): def _sf(value):
@@ -41,6 +46,8 @@ def _load_history_with_fallback(path):
def _load_snapshot_rows(path): def _load_snapshot_rows(path):
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return ProbabilitySnapshotRepository().load_all_rows()
rows = [] rows = []
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
return rows return rows
+56
View File
@@ -0,0 +1,56 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.analysis.probability_rollout import build_rollout_report # noqa: E402
def main():
parser = argparse.ArgumentParser(description="Judge whether EMOS is ready for primary rollout.")
parser.add_argument(
"--evaluation-report",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"evaluation_report.json",
),
)
parser.add_argument(
"--shadow-report",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"shadow_report.json",
),
)
parser.add_argument(
"--output",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"rollout_report.json",
),
)
args = parser.parse_args()
payload = build_rollout_report(args.evaluation_report, args.shadow_report)
output_dir = os.path.dirname(os.path.abspath(args.output))
if output_dir:
os.makedirs(output_dir, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(json.dumps(payload["decision"], ensure_ascii=False, indent=2))
print(f"saved rollout report to {args.output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def _load_json(path, default):
if not path or not os.path.exists(path):
return default
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
return data
def _load_jsonl(path):
rows = []
if not path or not os.path.exists(path):
return rows
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def main():
parser = argparse.ArgumentParser(description='Migrate runtime JSON state into SQLite.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
daily = _load_json(args.daily_records, {})
telegram = _load_json(args.telegram_state, {'last_by_city': {}, 'by_signature': {}})
snapshots = _load_jsonl(args.snapshots)
open_meteo = _load_json(args.open_meteo_cache, {'forecast': {}, 'ensemble': {}, 'multi_model': {}, 'saved_at': 0})
daily_count = DailyRecordRepository().replace_all(daily if isinstance(daily, dict) else {})
telegram_count = TelegramAlertStateRepository().replace_from_state(telegram if isinstance(telegram, dict) else {})
snapshot_count = ProbabilitySnapshotRepository().replace_all(snapshots)
cache_count = OpenMeteoCacheRepository().replace_payload(open_meteo if isinstance(open_meteo, dict) else {}, args.open_meteo_max_age)
print(json.dumps({
'daily_records_imported': daily_count,
'telegram_state_imported': telegram_count,
'snapshots_imported': snapshot_count,
'open_meteo_cache_imported': cache_count,
}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+101
View File
@@ -0,0 +1,101 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def _load_json(path, default):
if not path or not os.path.exists(path):
return default
with open(path, 'r', encoding='utf-8') as fh:
return json.load(fh)
def _load_jsonl(path):
rows = []
if not path or not os.path.exists(path):
return rows
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def _norm_json(obj):
return json.loads(json.dumps(obj, ensure_ascii=False, sort_keys=True))
def _norm_open_meteo_payload(payload):
payload = dict(payload or {})
payload.pop('saved_at', None)
return _norm_json(payload)
def main():
parser = argparse.ArgumentParser(description='Verify SQLite runtime state against legacy JSON files.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
file_daily = _load_json(args.daily_records, {})
file_telegram = _load_json(args.telegram_state, {'last_by_city': {}, 'by_signature': {}})
file_snapshots = _load_jsonl(args.snapshots)
file_cache = _load_json(args.open_meteo_cache, {'forecast': {}, 'ensemble': {}, 'multi_model': {}, 'saved_at': 0})
db_daily = DailyRecordRepository().load_all()
db_telegram = TelegramAlertStateRepository().load_state()
db_snapshots = ProbabilitySnapshotRepository().load_all_rows()
db_cache = OpenMeteoCacheRepository().load_payload(args.open_meteo_max_age)
report = {
'daily_records': {
'file_cities': len(file_daily or {}),
'db_cities': len(db_daily or {}),
'equal': _norm_json(file_daily or {}) == _norm_json(db_daily or {}),
},
'telegram_state': {
'equal': _norm_json(file_telegram or {}) == _norm_json(db_telegram or {}),
'file_last_by_city': len((file_telegram or {}).get('last_by_city') or {}),
'db_last_by_city': len((db_telegram or {}).get('last_by_city') or {}),
},
'snapshots': {
'file_rows': len(file_snapshots),
'db_rows': len(db_snapshots),
'equal': _norm_json(file_snapshots) == _norm_json(db_snapshots),
},
'open_meteo_cache': {
'file_forecast': len((file_cache or {}).get('forecast') or {}),
'db_forecast': len((db_cache or {}).get('forecast') or {}),
'equal': _norm_open_meteo_payload(file_cache or {}) == _norm_open_meteo_payload(db_cache or {}),
},
}
report['ok'] = all(section.get('equal') for section in report.values() if isinstance(section, dict))
print(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if report['ok'] else 1)
if __name__ == '__main__':
main()
+50 -1
View File
@@ -3,6 +3,13 @@ import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests import requests
from src.analysis.settlement_rounding import apply_city_settlement from src.analysis.settlement_rounding import apply_city_settlement
from loguru import logger
from src.database.runtime_state import (
DailyRecordRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
# Cross-platform file locking # Cross-platform file locking
import sys import sys
@@ -36,6 +43,7 @@ else:
# Simple memory cache to avoid blasting the disk if queried 10 times a minute # Simple memory cache to avoid blasting the disk if queried 10 times a minute
_history_cache = {} _history_cache = {}
_history_mtime = 0 _history_mtime = 0
_daily_record_repo = DailyRecordRepository()
def _sf(value): def _sf(value):
@@ -54,7 +62,24 @@ def _is_excluded_model_name(model_name: str) -> bool:
def load_history(filepath): def load_history(filepath):
global _history_cache, _history_mtime global _history_cache, _history_mtime
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
try:
data = _daily_record_repo.load_all()
_history_cache = data
return data
except Exception as e:
logger.error(f"Error loading daily records from sqlite, fallback to file: {e}")
if not os.path.exists(filepath): if not os.path.exists(filepath):
if mode == STATE_STORAGE_DUAL:
try:
data = _daily_record_repo.load_all()
_history_cache = data
return data
except Exception:
return {}
return {} return {}
try: try:
@@ -80,6 +105,18 @@ def load_history(filepath):
def save_history(filepath, data): def save_history(filepath, data):
global _history_cache, _history_mtime global _history_cache, _history_mtime
_history_cache = data _history_cache = data
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
try:
_daily_record_repo.replace_all(data)
except Exception as e:
logger.error(f"Error saving daily records to sqlite: {e}")
if mode == STATE_STORAGE_SQLITE:
return
if mode == STATE_STORAGE_SQLITE:
return
try: try:
with open(filepath, "w", encoding="utf-8") as f: with open(filepath, "w", encoding="utf-8") as f:
_lock_ex(f) _lock_ex(f)
@@ -251,6 +288,7 @@ def update_daily_record(
) )
history_file = os.path.join(project_root, "data", "daily_records.json") history_file = os.path.join(project_root, "data", "daily_records.json")
mode = get_state_storage_mode()
data = load_history(history_file) data = load_history(history_file)
if city_name not in data: if city_name not in data:
data[city_name] = {} data[city_name] = {}
@@ -359,7 +397,18 @@ def update_daily_record(
for d in old_dates: for d in old_dates:
del data[city][d] del data[city][d]
save_history(history_file, data) if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
try:
_daily_record_repo.upsert_record(city_name, date_str, existing)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
_daily_record_repo.delete_older_than(cutoff)
except Exception as e:
logger.error(f"Error upserting daily record to sqlite city={city_name} date={date_str}: {e}")
if mode == STATE_STORAGE_SQLITE:
raise
if mode != STATE_STORAGE_SQLITE:
save_history(history_file, data)
def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7): def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
def _load_json_file(path: str) -> Dict[str, Any]:
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _sf(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _append_reason(reasons: List[str], condition: bool, message: str) -> None:
if condition:
reasons.append(message)
def _top_shadow_regressions(by_city: Dict[str, Any], limit: int = 5) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for city, metrics in (by_city or {}).items():
if not isinstance(metrics, dict):
continue
rows.append(
{
"city": city,
"samples": int(metrics.get("samples") or 0),
"delta_mae": _sf(metrics.get("delta_mae")),
"delta_bucket_hit_rate": _sf(metrics.get("delta_bucket_hit_rate")),
"delta_bucket_brier": _sf(metrics.get("delta_bucket_brier")),
}
)
rows.sort(
key=lambda row: (
-(row["delta_bucket_brier"] or 0.0),
row["delta_bucket_hit_rate"] or 0.0,
-(row["delta_mae"] or 0.0),
)
)
return rows[:limit]
def judge_probability_rollout(
evaluation_report: Dict[str, Any],
shadow_report: Dict[str, Any],
) -> Dict[str, Any]:
thresholds = {
"evaluation_min_samples": 80,
"shadow_min_samples": 50,
"max_delta_mae": 0.05,
"min_delta_crps": -0.02,
"min_delta_bucket_hit_rate": 0.0,
"max_delta_bucket_brier_promote": 0.02,
"max_delta_bucket_brier_observe": 0.15,
}
eval_summary = (evaluation_report or {}).get("summary") or {}
eval_delta = eval_summary.get("delta") or {}
shadow_summary = (shadow_report or {}).get("summary") or {}
eval_samples = int(eval_summary.get("sample_count") or 0)
shadow_samples = int(shadow_summary.get("samples") or 0)
delta_crps = _sf(eval_delta.get("crps"))
delta_mae = _sf(eval_delta.get("mae"))
delta_hit = _sf(eval_delta.get("bucket_hit_rate"))
shadow_delta_mae = _sf(shadow_summary.get("delta_mae"))
shadow_delta_hit = _sf(shadow_summary.get("delta_bucket_hit_rate"))
shadow_delta_brier = _sf(shadow_summary.get("delta_bucket_brier"))
promote_reasons: List[str] = []
_append_reason(
promote_reasons,
eval_samples < thresholds["evaluation_min_samples"],
f"离线评估样本不足:{eval_samples} < {thresholds['evaluation_min_samples']}",
)
_append_reason(
promote_reasons,
shadow_samples < thresholds["shadow_min_samples"],
f"shadow 样本不足:{shadow_samples} < {thresholds['shadow_min_samples']}",
)
_append_reason(
promote_reasons,
delta_crps is None or delta_crps > thresholds["min_delta_crps"],
f"离线 CRPS 改善不足:delta={delta_crps}",
)
_append_reason(
promote_reasons,
delta_mae is None or delta_mae > thresholds["max_delta_mae"],
f"离线 MAE 退化超限:delta={delta_mae}",
)
_append_reason(
promote_reasons,
delta_hit is None or delta_hit < thresholds["min_delta_bucket_hit_rate"],
f"离线 bucket 命中率下降:delta={delta_hit}",
)
_append_reason(
promote_reasons,
shadow_delta_mae is None or shadow_delta_mae > thresholds["max_delta_mae"],
f"shadow MAE 退化超限:delta={shadow_delta_mae}",
)
_append_reason(
promote_reasons,
shadow_delta_hit is None or shadow_delta_hit < thresholds["min_delta_bucket_hit_rate"],
f"shadow bucket 命中率下降:delta={shadow_delta_hit}",
)
_append_reason(
promote_reasons,
shadow_delta_brier is None
or shadow_delta_brier > thresholds["max_delta_bucket_brier_promote"],
f"shadow bucket brier 退化超限:delta={shadow_delta_brier}",
)
if not promote_reasons:
decision = "promote"
summary = "离线与 shadow 指标均达标,可以考虑切换 emos_primary。"
else:
observe_reasons: List[str] = []
_append_reason(
observe_reasons,
eval_samples < thresholds["evaluation_min_samples"],
f"离线评估样本不足:{eval_samples}",
)
_append_reason(
observe_reasons,
delta_crps is None or delta_crps > thresholds["min_delta_crps"],
f"离线 CRPS 改善不足:delta={delta_crps}",
)
_append_reason(
observe_reasons,
delta_mae is None or delta_mae > thresholds["max_delta_mae"],
f"离线 MAE 退化超限:delta={delta_mae}",
)
_append_reason(
observe_reasons,
delta_hit is None or delta_hit < thresholds["min_delta_bucket_hit_rate"],
f"离线 bucket 命中率下降:delta={delta_hit}",
)
_append_reason(
observe_reasons,
shadow_samples < thresholds["shadow_min_samples"],
f"shadow 样本不足:{shadow_samples}",
)
_append_reason(
observe_reasons,
shadow_delta_mae is None or shadow_delta_mae > thresholds["max_delta_mae"],
f"shadow MAE 退化超限:delta={shadow_delta_mae}",
)
_append_reason(
observe_reasons,
shadow_delta_hit is None or shadow_delta_hit < thresholds["min_delta_bucket_hit_rate"],
f"shadow bucket 命中率下降:delta={shadow_delta_hit}",
)
_append_reason(
observe_reasons,
shadow_delta_brier is None
or shadow_delta_brier > thresholds["max_delta_bucket_brier_observe"],
f"shadow bucket brier 退化偏大:delta={shadow_delta_brier}",
)
if not observe_reasons:
decision = "observe"
summary = "离线评估达标,但 shadow 仍需继续观察,暂不切主路径。"
else:
decision = "hold"
summary = "当前指标不足以切换 emos_primary,应继续保持 shadow。"
return {
"decision": decision,
"ready_for_primary": decision == "promote",
"summary": summary,
"thresholds": thresholds,
"evaluation": {
"sample_count": eval_samples,
"delta_crps": delta_crps,
"delta_mae": delta_mae,
"delta_bucket_hit_rate": delta_hit,
},
"shadow": {
"sample_count": shadow_samples,
"delta_mae": shadow_delta_mae,
"delta_bucket_hit_rate": shadow_delta_hit,
"delta_bucket_brier": shadow_delta_brier,
},
"blocking_reasons": promote_reasons,
"worst_shadow_regressions": _top_shadow_regressions(
(shadow_report or {}).get("by_city") or {}
),
}
def build_rollout_report(
evaluation_report_path: str,
shadow_report_path: str,
) -> Dict[str, Any]:
evaluation_report = _load_json_file(evaluation_report_path)
shadow_report = _load_json_file(shadow_report_path)
decision = judge_probability_rollout(evaluation_report, shadow_report)
return {
"evaluation_report_path": evaluation_report_path,
"shadow_report_path": shadow_report_path,
"evaluation_report_exists": os.path.exists(evaluation_report_path),
"shadow_report_exists": os.path.exists(shadow_report_path),
"decision": decision,
}
+24 -3
View File
@@ -5,10 +5,18 @@ import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from src.database.runtime_state import (
ProbabilitySnapshotRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
DEDUP_SCAN_LINES = 200 DEDUP_SCAN_LINES = 200
MU_THRESHOLD = 0.2 MU_THRESHOLD = 0.2
SIGMA_THRESHOLD = 0.15 SIGMA_THRESHOLD = 0.15
MAX_SO_FAR_THRESHOLD = 0.2 MAX_SO_FAR_THRESHOLD = 0.2
_snapshot_repo = ProbabilitySnapshotRepository()
def _sf(value: Any) -> Optional[float]: def _sf(value: Any) -> Optional[float]:
@@ -79,7 +87,15 @@ def _load_recent_rows(path: str, max_lines: int = DEDUP_SCAN_LINES) -> List[Dict
def _should_skip_append(path: str, payload: Dict[str, Any]) -> bool: def _should_skip_append(path: str, payload: Dict[str, Any]) -> bool:
recent_rows = _load_recent_rows(path) mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
recent_rows = _snapshot_repo.load_recent_rows(
str(payload.get("city") or ""),
str(payload.get("date") or ""),
DEDUP_SCAN_LINES,
)
else:
recent_rows = _load_recent_rows(path)
city = payload.get("city") city = payload.get("city")
date_str = payload.get("date") date_str = payload.get("date")
if not city or not date_str: if not city or not date_str:
@@ -183,5 +199,10 @@ def append_probability_snapshot(
if _should_skip_append(path, payload): if _should_skip_append(path, payload):
return return
with open(path, "a", encoding="utf-8") as fh: mode = get_state_storage_mode()
fh.write(json.dumps(payload, ensure_ascii=False) + "\n") if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
_snapshot_repo.append_snapshot(payload)
if mode != STATE_STORAGE_SQLITE:
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
+9
View File
@@ -8,6 +8,8 @@ from typing import Dict, List, Optional
import requests import requests
from loguru import logger from loguru import logger
from src.utils.metrics import record_source_call
class MetarSourceMixin: class MetarSourceMixin:
def get_icao_code(self, city: str) -> Optional[str]: def get_icao_code(self, city: str) -> Optional[str]:
@@ -24,9 +26,11 @@ class MetarSourceMixin:
self, city: str, use_fahrenheit: bool = False, utc_offset: int = 0 self, city: str, use_fahrenheit: bool = False, utc_offset: int = 0
) -> Optional[Dict]: ) -> Optional[Dict]:
"""从 NOAA Aviation Weather Center 获取 METAR 航空气象数据。""" """从 NOAA Aviation Weather Center 获取 METAR 航空气象数据。"""
started = time.perf_counter()
icao = self.get_icao_code(city) icao = self.get_icao_code(city)
if not icao: if not icao:
logger.warning(f"未找到城市 {city} 对应的 ICAO 代码") logger.warning(f"未找到城市 {city} 对应的 ICAO 代码")
record_source_call("metar", "current", "missing_icao", (time.perf_counter() - started) * 1000.0)
return None return None
cache_key = f"{icao}:{utc_offset}:{use_fahrenheit}" cache_key = f"{icao}:{utc_offset}:{use_fahrenheit}"
@@ -35,6 +39,7 @@ class MetarSourceMixin:
cached = self._metar_cache.get(cache_key) cached = self._metar_cache.get(cache_key)
if cached and now_ts - cached["t"] < self.metar_cache_ttl_sec: if cached and now_ts - cached["t"] < self.metar_cache_ttl_sec:
logger.debug(f"METAR cache hit {icao} age={int(now_ts - cached['t'])}s") logger.debug(f"METAR cache hit {icao} age={int(now_ts - cached['t'])}s")
record_source_call("metar", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
return cached["d"] return cached["d"]
try: try:
@@ -201,6 +206,7 @@ class MetarSourceMixin:
) )
with self._metar_cache_lock: with self._metar_cache_lock:
self._metar_cache[cache_key] = {"d": result, "t": now_ts} self._metar_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except requests.exceptions.RequestException as exc: except requests.exceptions.RequestException as exc:
@@ -209,10 +215,13 @@ class MetarSourceMixin:
stale = self._metar_cache.get(cache_key) stale = self._metar_cache.get(cache_key)
if stale: if stale:
logger.warning(f"METAR {icao} 请求失败,使用缓存回退") logger.warning(f"METAR {icao} 请求失败,使用缓存回退")
record_source_call("metar", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
return stale["d"] return stale["d"]
record_source_call("metar", "current", "error", (time.perf_counter() - started) * 1000.0)
return None return None
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
logger.error(f"METAR 数据解析失败 ({icao}): {exc}") logger.error(f"METAR 数据解析失败 ({icao}): {exc}")
record_source_call("metar", "current", "parse_error", (time.perf_counter() - started) * 1000.0)
return None return None
def fetch_metar_nearby_cluster(self, icaos: List[str], use_fahrenheit: bool = False) -> list: def fetch_metar_nearby_cluster(self, icaos: List[str], use_fahrenheit: bool = False) -> list:
+34
View File
@@ -5,12 +5,15 @@ from typing import Dict, Optional
from loguru import logger from loguru import logger
from src.utils.metrics import record_source_call
class MgmSourceMixin: class MgmSourceMixin:
def fetch_from_mgm(self, istno: str) -> Optional[Dict]: def fetch_from_mgm(self, istno: str) -> Optional[Dict]:
""" """
从土耳其气象局 (MGM) 获取实时数据和预测 (由用户提供其内部 API) 从土耳其气象局 (MGM) 获取实时数据和预测 (由用户提供其内部 API)
""" """
started = datetime.now()
base_url = "https://servis.mgm.gov.tr/web" base_url = "https://servis.mgm.gov.tr/web"
# 必须带 Origin,否则会被反爬拦截 # 必须带 Origin,否则会被反爬拦截
headers = { headers = {
@@ -202,9 +205,21 @@ class MgmSourceMixin:
f"hourly points={len(hourly_rows)}, horizon={horizon_hours:.1f}h" f"hourly points={len(hourly_rows)}, horizon={horizon_hours:.1f}h"
) )
record_source_call(
"mgm",
"station",
"success" if "current" in results else "empty",
(datetime.now() - started).total_seconds() * 1000.0,
)
return results if "current" in results else None return results if "current" in results else None
except Exception as e: except Exception as e:
logger.error(f"MGM API 请求失败 ({istno}): {e}") logger.error(f"MGM API 请求失败 ({istno}): {e}")
record_source_call(
"mgm",
"station",
"error",
(datetime.now() - started).total_seconds() * 1000.0,
)
return None return None
def fetch_mgm_nearby_stations(self, province: str, root_ist_no: str = None) -> list: def fetch_mgm_nearby_stations(self, province: str, root_ist_no: str = None) -> list:
@@ -212,6 +227,7 @@ class MgmSourceMixin:
获取一个土耳其省份内所有气象站的当前温度及经纬度 获取一个土耳其省份内所有气象站的当前温度及经纬度
使用多线程辅助抓取,因为直接通过 il={province} 往往只返回 1 个站。 使用多线程辅助抓取,因为直接通过 il={province} 往往只返回 1 个站。
""" """
started = datetime.now()
base_url = "https://servis.mgm.gov.tr/web" base_url = "https://servis.mgm.gov.tr/web"
headers = { headers = {
"Origin": "https://www.mgm.gov.tr", "Origin": "https://www.mgm.gov.tr",
@@ -243,6 +259,12 @@ class MgmSourceMixin:
if not province_ist_nos: if not province_ist_nos:
logger.warning(f"MGM 找不到省份 {province} 的站点元数据") logger.warning(f"MGM 找不到省份 {province} 的站点元数据")
record_source_call(
"mgm",
"nearby",
"empty",
(datetime.now() - started).total_seconds() * 1000.0,
)
return [] return []
# 同时确保我们关心的几个核心站一定在里面 # 同时确保我们关心的几个核心站一定在里面
@@ -318,8 +340,20 @@ class MgmSourceMixin:
}) })
logger.info(f"📍 MGM 周边测站: 成功并发抓取 {len(results)}{province} 站点的实时气温") logger.info(f"📍 MGM 周边测站: 成功并发抓取 {len(results)}{province} 站点的实时气温")
record_source_call(
"mgm",
"nearby",
"success" if results else "empty",
(datetime.now() - started).total_seconds() * 1000.0,
)
return results return results
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch MGM nearby stations for {province}: {e}") logger.error(f"Failed to fetch MGM nearby stations for {province}: {e}")
record_source_call(
"mgm",
"nearby",
"error",
(datetime.now() - started).total_seconds() * 1000.0,
)
return [] return []
+35 -4
View File
@@ -6,6 +6,8 @@ from typing import Dict, Optional
from loguru import logger from loguru import logger
from src.utils.metrics import record_source_call
class NwsOpenMeteoSourceMixin: class NwsOpenMeteoSourceMixin:
def fetch_nws(self, lat: float, lon: float) -> Optional[Dict]: def fetch_nws(self, lat: float, lon: float) -> Optional[Dict]:
@@ -13,6 +15,7 @@ class NwsOpenMeteoSourceMixin:
从 NWS (美国国家气象局) 获取高精度预报 从 NWS (美国国家气象局) 获取高精度预报
仅适用于美国城市,全球 VPS 均可访问 仅适用于美国城市,全球 VPS 均可访问
""" """
started = time.perf_counter()
try: try:
# 1. 获取网格点 # 1. 获取网格点
points_url = f"https://api.weather.gov/points/{lat},{lon}" points_url = f"https://api.weather.gov/points/{lat},{lon}"
@@ -28,6 +31,7 @@ class NwsOpenMeteoSourceMixin:
forecast_url = properties.get("forecast") forecast_url = properties.get("forecast")
hourly_url = properties.get("forecastHourly") hourly_url = properties.get("forecastHourly")
if not forecast_url: if not forecast_url:
record_source_call("nws", "forecast", "empty", (time.perf_counter() - started) * 1000.0)
return None return None
# 2. 获取预报 # 2. 获取预报
@@ -39,6 +43,7 @@ class NwsOpenMeteoSourceMixin:
periods = forecast_data.get("properties", {}).get("periods", []) periods = forecast_data.get("properties", {}).get("periods", [])
if not periods: if not periods:
record_source_call("nws", "forecast", "empty", (time.perf_counter() - started) * 1000.0)
return None return None
hourly_periods = [] hourly_periods = []
@@ -89,7 +94,7 @@ class NwsOpenMeteoSourceMixin:
today_high = p.get("temperature") today_high = p.get("temperature")
break break
return { result = {
"source": "nws", "source": "nws",
"today_high": today_high, "today_high": today_high,
"unit": "fahrenheit", "unit": "fahrenheit",
@@ -124,8 +129,11 @@ class NwsOpenMeteoSourceMixin:
], ],
"active_alerts": active_alerts, "active_alerts": active_alerts,
} }
record_source_call("nws", "forecast", "success", (time.perf_counter() - started) * 1000.0)
return result
except Exception as e: except Exception as e:
logger.warning(f"NWS 请求失败: {e}") logger.warning(f"NWS 请求失败: {e}")
record_source_call("nws", "forecast", "error", (time.perf_counter() - started) * 1000.0)
return None return None
def fetch_from_open_meteo( def fetch_from_open_meteo(
@@ -144,6 +152,7 @@ class NwsOpenMeteoSourceMixin:
forecast_days: Number of forecast days to fetch (default 14 to cover all market dates) forecast_days: Number of forecast days to fetch (default 14 to cover all market dates)
use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets) use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets)
""" """
started = time.perf_counter()
cache_key = ( cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:" f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
f"{forecast_days}:{'f' if use_fahrenheit else 'c'}" f"{forecast_days}:{'f' if use_fahrenheit else 'c'}"
@@ -156,9 +165,11 @@ class NwsOpenMeteoSourceMixin:
remaining = int(self._open_meteo_rate_limit_until - now_ts) remaining = int(self._open_meteo_rate_limit_until - now_ts)
logger.debug(f"Open-Meteo 冷却期中,跳过请求,还需 {remaining}s") logger.debug(f"Open-Meteo 冷却期中,跳过请求,还需 {remaining}s")
with self._open_meteo_cache_lock: with self._open_meteo_cache_lock:
stale = self._open_meteo_cache.get(cache_key) stale = self._open_meteo_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
return dict(stale["data"]) record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
return dict(stale["data"])
record_source_call("open_meteo", "forecast", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
return None return None
with self._open_meteo_cache_lock: with self._open_meteo_cache_lock:
cached = self._open_meteo_cache.get(cache_key) cached = self._open_meteo_cache.get(cache_key)
@@ -168,6 +179,7 @@ class NwsOpenMeteoSourceMixin:
): ):
cached_data = cached.get("data") cached_data = cached.get("data")
if isinstance(cached_data, dict): if isinstance(cached_data, dict):
record_source_call("open_meteo", "forecast", "cache_hit", (time.perf_counter() - started) * 1000.0)
return dict(cached_data) return dict(cached_data)
try: try:
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
@@ -259,6 +271,7 @@ class NwsOpenMeteoSourceMixin:
"data": dict(result), "data": dict(result),
} }
self._flush_open_meteo_disk_cache() self._flush_open_meteo_disk_cache()
record_source_call("open_meteo", "forecast", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except Exception as e: except Exception as e:
status_code = getattr(getattr(e, "response", None), "status_code", None) status_code = getattr(getattr(e, "response", None), "status_code", None)
@@ -287,7 +300,9 @@ class NwsOpenMeteoSourceMixin:
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"]) fallback = dict(stale["data"])
fallback["stale_cache"] = True fallback["stale_cache"] = True
record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
return fallback return fallback
record_source_call("open_meteo", "forecast", "error", (time.perf_counter() - started) * 1000.0)
return None return None
def fetch_ensemble( def fetch_ensemble(
@@ -300,6 +315,7 @@ class NwsOpenMeteoSourceMixin:
从 Open-Meteo Ensemble API 获取 51 成员集合预报 从 Open-Meteo Ensemble API 获取 51 成员集合预报
用于计算预报不确定性范围(散度) 用于计算预报不确定性范围(散度)
""" """
started = time.perf_counter()
cache_key = ( cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:" f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
f"{'f' if use_fahrenheit else 'c'}" f"{'f' if use_fahrenheit else 'c'}"
@@ -314,7 +330,9 @@ class NwsOpenMeteoSourceMixin:
with self._ensemble_cache_lock: with self._ensemble_cache_lock:
stale = self._ensemble_cache.get(cache_key) stale = self._ensemble_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
record_source_call("open_meteo", "ensemble", "stale_cache", (time.perf_counter() - started) * 1000.0)
return dict(stale["data"]) return dict(stale["data"])
record_source_call("open_meteo", "ensemble", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
return None return None
with self._ensemble_cache_lock: with self._ensemble_cache_lock:
@@ -326,6 +344,7 @@ class NwsOpenMeteoSourceMixin:
): ):
cached_data = cached.get("data") cached_data = cached.get("data")
if isinstance(cached_data, dict): if isinstance(cached_data, dict):
record_source_call("open_meteo", "ensemble", "cache_hit", (time.perf_counter() - started) * 1000.0)
return dict(cached_data) return dict(cached_data)
try: try:
url = "https://ensemble-api.open-meteo.com/v1/ensemble" url = "https://ensemble-api.open-meteo.com/v1/ensemble"
@@ -371,6 +390,7 @@ class NwsOpenMeteoSourceMixin:
if len(today_highs) < 3: if len(today_highs) < 3:
logger.warning(f"Ensemble 数据不足: 仅获取 {len(today_highs)} 个成员") logger.warning(f"Ensemble 数据不足: 仅获取 {len(today_highs)} 个成员")
record_source_call("open_meteo", "ensemble", "empty", (time.perf_counter() - started) * 1000.0)
return None return None
today_highs.sort() today_highs.sort()
@@ -400,6 +420,7 @@ class NwsOpenMeteoSourceMixin:
"data": dict(result), "data": dict(result),
} }
self._flush_open_meteo_disk_cache() self._flush_open_meteo_disk_cache()
record_source_call("open_meteo", "ensemble", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except Exception as e: except Exception as e:
status_code = getattr(getattr(e, "response", None), "status_code", None) status_code = getattr(getattr(e, "response", None), "status_code", None)
@@ -425,7 +446,9 @@ class NwsOpenMeteoSourceMixin:
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"]) fallback = dict(stale["data"])
fallback["stale_cache"] = True fallback["stale_cache"] = True
record_source_call("open_meteo", "ensemble", "stale_cache", (time.perf_counter() - started) * 1000.0)
return fallback return fallback
record_source_call("open_meteo", "ensemble", "error", (time.perf_counter() - started) * 1000.0)
return None return None
def fetch_multi_model( def fetch_multi_model(
@@ -448,6 +471,7 @@ class NwsOpenMeteoSourceMixin:
返回 3 天的预报数据,支持今日+明日共识分析 返回 3 天的预报数据,支持今日+明日共识分析
""" """
started = time.perf_counter()
cache_city = str(city or "").strip().lower() cache_city = str(city or "").strip().lower()
cache_key = ( cache_key = (
f"{round(float(lat), 4)}:{round(float(lon), 4)}:{cache_city}:" f"{round(float(lat), 4)}:{round(float(lon), 4)}:{cache_city}:"
@@ -463,7 +487,9 @@ class NwsOpenMeteoSourceMixin:
with self._multi_model_cache_lock: with self._multi_model_cache_lock:
stale = self._multi_model_cache.get(cache_key) stale = self._multi_model_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
return dict(stale["data"]) return dict(stale["data"])
record_source_call("open_meteo", "multi_model", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
return None return None
with self._multi_model_cache_lock: with self._multi_model_cache_lock:
@@ -475,6 +501,7 @@ class NwsOpenMeteoSourceMixin:
): ):
cached_data = cached.get("data") cached_data = cached.get("data")
if isinstance(cached_data, dict): if isinstance(cached_data, dict):
record_source_call("open_meteo", "multi_model", "cache_hit", (time.perf_counter() - started) * 1000.0)
return dict(cached_data) return dict(cached_data)
try: try:
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
@@ -524,6 +551,7 @@ class NwsOpenMeteoSourceMixin:
if not daily_forecasts: if not daily_forecasts:
logger.warning("Multi-model: 无有效模型数据") logger.warning("Multi-model: 无有效模型数据")
record_source_call("open_meteo", "multi_model", "empty", (time.perf_counter() - started) * 1000.0)
return None return None
# 今天的预报 (向后兼容) # 今天的预报 (向后兼容)
@@ -548,6 +576,7 @@ class NwsOpenMeteoSourceMixin:
"data": dict(result), "data": dict(result),
} }
self._flush_open_meteo_disk_cache() self._flush_open_meteo_disk_cache()
record_source_call("open_meteo", "multi_model", "success", (time.perf_counter() - started) * 1000.0)
return result return result
except Exception as e: except Exception as e:
status_code = getattr(getattr(e, "response", None), "status_code", None) status_code = getattr(getattr(e, "response", None), "status_code", None)
@@ -573,6 +602,8 @@ class NwsOpenMeteoSourceMixin:
if stale and isinstance(stale.get("data"), dict): if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"]) fallback = dict(stale["data"])
fallback["stale_cache"] = True fallback["stale_cache"] = True
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
return fallback return fallback
record_source_call("open_meteo", "multi_model", "error", (time.perf_counter() - started) * 1000.0)
return None return None
+76 -7
View File
@@ -5,11 +5,30 @@ import os
import time import time
from loguru import logger from loguru import logger
from src.database.runtime_state import (
OpenMeteoCacheRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
_open_meteo_cache_repo = OpenMeteoCacheRepository()
class OpenMeteoCacheMixin: class OpenMeteoCacheMixin:
def _load_open_meteo_disk_cache(self) -> None: def _load_open_meteo_disk_cache(self) -> None:
"""启动时从磁盘加载 Open-Meteo 三类缓存,避免重启后冷启动打爆 API""" """启动时从磁盘加载 Open-Meteo 三类缓存,避免重启后冷启动打爆 API"""
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
try:
saved = _open_meteo_cache_repo.load_payload(self._disk_cache_max_age_sec)
loaded = self._merge_open_meteo_payload(saved)
self._disk_cache_last_mtime = _open_meteo_cache_repo.latest_updated_at()
if loaded:
logger.info(f"✅ 从 SQLite 加载 Open-Meteo 缓存 {loaded}")
return
except Exception as exc:
logger.error(f"SQLite Open-Meteo 缓存加载失败,fallback file: {exc}")
try: try:
path = self._disk_cache_path path = self._disk_cache_path
if not os.path.exists(path): if not os.path.exists(path):
@@ -58,11 +77,26 @@ class OpenMeteoCacheMixin:
self._disk_cache_last_mtime = current_mtime self._disk_cache_last_mtime = current_mtime
if loaded: if loaded:
logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})") logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})")
if mode == STATE_STORAGE_DUAL:
try:
_open_meteo_cache_repo.replace_payload(saved, self._disk_cache_max_age_sec)
except Exception as exc:
logger.warning(f"SQLite Open-Meteo 缓存同步失败: {exc}")
except Exception as exc: except Exception as exc:
logger.warning(f"磁盘缓存加载失败(首次启动不影响运行): {exc}") logger.warning(f"磁盘缓存加载失败(首次启动不影响运行): {exc}")
def _maybe_reload_open_meteo_disk_cache(self) -> None: def _maybe_reload_open_meteo_disk_cache(self) -> None:
"""跨进程共享缓存:当缓存文件有更新时增量重载到当前进程内存""" """跨进程共享缓存:当缓存文件有更新时增量重载到当前进程内存"""
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
try:
latest_updated_at = _open_meteo_cache_repo.latest_updated_at()
if latest_updated_at <= self._disk_cache_last_mtime:
return
self._load_open_meteo_disk_cache()
except Exception:
pass
return
try: try:
path = self._disk_cache_path path = self._disk_cache_path
if not os.path.exists(path): if not os.path.exists(path):
@@ -76,8 +110,8 @@ class OpenMeteoCacheMixin:
def _flush_open_meteo_disk_cache(self) -> None: def _flush_open_meteo_disk_cache(self) -> None:
"""将三类 Open-Meteo 内存缓存持久化到磁盘""" """将三类 Open-Meteo 内存缓存持久化到磁盘"""
mode = get_state_storage_mode()
try: try:
os.makedirs(os.path.dirname(self._disk_cache_path), exist_ok=True)
with self._open_meteo_cache_lock: with self._open_meteo_cache_lock:
forecast_snapshot = dict(self._open_meteo_cache) forecast_snapshot = dict(self._open_meteo_cache)
with self._ensemble_cache_lock: with self._ensemble_cache_lock:
@@ -90,15 +124,50 @@ class OpenMeteoCacheMixin:
"multi_model": multi_model_snapshot, "multi_model": multi_model_snapshot,
"saved_at": time.time(), "saved_at": time.time(),
} }
with self._disk_cache_lock: if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
tmp_path = self._disk_cache_path + ".tmp" _open_meteo_cache_repo.replace_payload(payload, self._disk_cache_max_age_sec)
with open(tmp_path, "w", encoding="utf-8") as f: self._disk_cache_last_mtime = _open_meteo_cache_repo.latest_updated_at()
json.dump(payload, f) if mode != STATE_STORAGE_SQLITE:
os.replace(tmp_path, self._disk_cache_path) os.makedirs(os.path.dirname(self._disk_cache_path), exist_ok=True)
self._disk_cache_last_mtime = os.path.getmtime(self._disk_cache_path) with self._disk_cache_lock:
tmp_path = self._disk_cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.replace(tmp_path, self._disk_cache_path)
self._disk_cache_last_mtime = max(
self._disk_cache_last_mtime,
os.path.getmtime(self._disk_cache_path),
)
except Exception as exc: except Exception as exc:
logger.warning(f"磁盘缓存写入失败: {exc}") logger.warning(f"磁盘缓存写入失败: {exc}")
def _merge_open_meteo_payload(self, saved: dict) -> int:
now = time.time()
max_age = max(600, self._disk_cache_max_age_sec)
loaded = 0
with self._open_meteo_cache_lock:
for key, entry in (saved.get("forecast", {}) or {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._open_meteo_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._open_meteo_cache[key] = entry
loaded += 1
with self._ensemble_cache_lock:
for key, entry in (saved.get("ensemble", {}) or {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._ensemble_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._ensemble_cache[key] = entry
loaded += 1
with self._multi_model_cache_lock:
for key, entry in (saved.get("multi_model", {}) or {}).items():
if now - float(entry.get("t", 0)) < max_age:
old = self._multi_model_cache.get(key)
if old is None or float(entry.get("t", 0)) >= float(old.get("t", 0)):
self._multi_model_cache[key] = entry
loaded += 1
return loaded
def _wait_open_meteo_slot(self, endpoint: str) -> None: def _wait_open_meteo_slot(self, endpoint: str) -> None:
"""Simple per-process rate gate for Open-Meteo endpoints.""" """Simple per-process rate gate for Open-Meteo endpoints."""
min_interval = self._open_meteo_min_interval_sec min_interval = self._open_meteo_min_interval_sec
+500
View File
@@ -0,0 +1,500 @@
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from loguru import logger
from src.database.db_manager import DBManager
STATE_STORAGE_FILE = "file"
STATE_STORAGE_DUAL = "dual"
STATE_STORAGE_SQLITE = "sqlite"
VALID_STATE_STORAGE_MODES = {
STATE_STORAGE_FILE,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
}
_LOGGED_MODES: set[str] = set()
def get_state_storage_mode() -> str:
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or STATE_STORAGE_DUAL).strip().lower()
if raw not in VALID_STATE_STORAGE_MODES:
logger.warning(
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {STATE_STORAGE_DUAL}"
)
raw = STATE_STORAGE_DUAL
if raw not in _LOGGED_MODES:
logger.info(f"runtime state storage mode={raw}")
_LOGGED_MODES.add(raw)
return raw
class RuntimeStateDB:
_instance: Optional["RuntimeStateDB"] = None
_instance_lock = threading.Lock()
def __init__(self, db_path: Optional[str] = None):
self.db_path = DBManager(db_path).db_path
self._init_tables()
@classmethod
def instance(cls) -> "RuntimeStateDB":
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_tables(self) -> None:
with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS daily_records_store (
city TEXT NOT NULL,
target_date TEXT NOT NULL,
actual_high REAL,
deb_prediction REAL,
mu REAL,
updated_at REAL NOT NULL,
payload_json TEXT NOT NULL,
PRIMARY KEY (city, target_date)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS telegram_alert_last_by_city (
city TEXT PRIMARY KEY,
signature TEXT,
trigger_key TEXT,
severity TEXT,
ts INTEGER,
active INTEGER DEFAULT 0,
cleared_ts INTEGER,
evidence_json TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS telegram_alert_signature_state (
signature TEXT PRIMARY KEY,
ts INTEGER NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS probability_training_snapshots_store (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL,
target_date TEXT NOT NULL,
timestamp TEXT NOT NULL,
raw_mu REAL,
raw_sigma REAL,
max_so_far REAL,
peak_status TEXT,
probability_mode TEXT,
legacy_top_bucket INTEGER,
shadow_top_bucket INTEGER,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_probability_snapshot_city_date ON probability_training_snapshots_store(city, target_date, id DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS open_meteo_cache_store (
source_kind TEXT NOT NULL,
cache_key TEXT NOT NULL,
updated_at REAL NOT NULL,
expires_at REAL,
payload_json TEXT NOT NULL,
PRIMARY KEY (source_kind, cache_key)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_open_meteo_cache_expires ON open_meteo_cache_store(source_kind, expires_at)"
)
conn.commit()
class DailyRecordRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def load_all(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
out: Dict[str, Dict[str, Dict[str, Any]]] = {}
with self.db.connect() as conn:
rows = conn.execute(
"SELECT city, target_date, payload_json FROM daily_records_store ORDER BY city, target_date"
).fetchall()
for row in rows:
try:
payload = json.loads(row["payload_json"])
except Exception:
continue
city = str(row["city"])
date_str = str(row["target_date"])
out.setdefault(city, {})[date_str] = payload
return out
def upsert_record(self, city: str, target_date: str, record: Dict[str, Any]) -> None:
payload_json = json.dumps(record, ensure_ascii=False)
updated_at = time.time()
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO daily_records_store (
city, target_date, actual_high, deb_prediction, mu, updated_at, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(city, target_date) DO UPDATE SET
actual_high = excluded.actual_high,
deb_prediction = excluded.deb_prediction,
mu = excluded.mu,
updated_at = excluded.updated_at,
payload_json = excluded.payload_json
""",
(
city,
target_date,
record.get("actual_high"),
record.get("deb_prediction"),
record.get("mu"),
updated_at,
payload_json,
),
)
conn.commit()
def replace_all(self, data: Dict[str, Dict[str, Dict[str, Any]]]) -> int:
count = 0
with self.db.connect() as conn:
conn.execute("DELETE FROM daily_records_store")
for city, city_rows in (data or {}).items():
if not isinstance(city_rows, dict):
continue
for target_date, record in city_rows.items():
payload_json = json.dumps(record, ensure_ascii=False)
conn.execute(
"""
INSERT INTO daily_records_store (
city, target_date, actual_high, deb_prediction, mu, updated_at, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
city,
target_date,
record.get("actual_high"),
record.get("deb_prediction"),
record.get("mu"),
time.time(),
payload_json,
),
)
count += 1
conn.commit()
return count
def delete_older_than(self, cutoff_date: str) -> int:
with self.db.connect() as conn:
cur = conn.execute(
"DELETE FROM daily_records_store WHERE target_date < ?",
(cutoff_date,),
)
conn.commit()
return int(cur.rowcount or 0)
class TelegramAlertStateRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def load_state(self) -> Dict[str, Any]:
state = {"last_by_city": {}, "by_signature": {}}
with self.db.connect() as conn:
city_rows = conn.execute(
"SELECT city, signature, trigger_key, severity, ts, active, cleared_ts, evidence_json FROM telegram_alert_last_by_city"
).fetchall()
sig_rows = conn.execute(
"SELECT signature, ts FROM telegram_alert_signature_state"
).fetchall()
for row in city_rows:
entry = {
"signature": row["signature"],
"trigger_key": row["trigger_key"],
"severity": row["severity"],
"ts": row["ts"],
"active": bool(row["active"]),
}
if row["cleared_ts"] is not None:
entry["cleared_ts"] = row["cleared_ts"]
if row["evidence_json"]:
try:
entry["evidence"] = json.loads(row["evidence_json"])
except Exception:
pass
state["last_by_city"][str(row["city"])] = entry
for row in sig_rows:
state["by_signature"][str(row["signature"])] = int(row["ts"] or 0)
return state
def save_state(self, state: Dict[str, Any]) -> None:
last_by_city = state.get("last_by_city") or {}
by_signature = state.get("by_signature") or {}
with self.db.connect() as conn:
conn.execute("DELETE FROM telegram_alert_last_by_city")
conn.execute("DELETE FROM telegram_alert_signature_state")
for city, row in last_by_city.items():
if not isinstance(row, dict):
continue
conn.execute(
"""
INSERT INTO telegram_alert_last_by_city (
city, signature, trigger_key, severity, ts, active, cleared_ts, evidence_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
city,
row.get("signature"),
row.get("trigger_key"),
row.get("severity"),
int(row.get("ts") or 0),
1 if row.get("active") else 0,
row.get("cleared_ts"),
json.dumps(row.get("evidence"), ensure_ascii=False)
if row.get("evidence") is not None
else None,
),
)
for signature, ts in by_signature.items():
conn.execute(
"INSERT INTO telegram_alert_signature_state (signature, ts) VALUES (?, ?)",
(signature, int(ts or 0)),
)
conn.commit()
def replace_from_state(self, state: Dict[str, Any]) -> int:
self.save_state(state)
return len((state.get("last_by_city") or {})) + len((state.get("by_signature") or {}))
class ProbabilitySnapshotRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def append_snapshot(self, payload: Dict[str, Any]) -> None:
legacy_top = _top_bucket(payload.get("prob_snapshot"))
shadow_top = _top_bucket(payload.get("shadow_prob_snapshot"))
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO probability_training_snapshots_store (
city, target_date, timestamp, raw_mu, raw_sigma, max_so_far,
peak_status, probability_mode, legacy_top_bucket, shadow_top_bucket, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.get("city"),
payload.get("date"),
payload.get("timestamp"),
payload.get("raw_mu"),
payload.get("raw_sigma"),
payload.get("max_so_far"),
payload.get("peak_status"),
payload.get("probability_mode"),
legacy_top,
shadow_top,
json.dumps(payload, ensure_ascii=False),
),
)
conn.commit()
def load_recent_rows(self, city: str, target_date: str, limit: int) -> List[Dict[str, Any]]:
with self.db.connect() as conn:
rows = conn.execute(
"""
SELECT payload_json
FROM probability_training_snapshots_store
WHERE city = ? AND target_date = ?
ORDER BY id DESC
LIMIT ?
""",
(city, target_date, int(limit)),
).fetchall()
out = []
for row in rows:
try:
out.append(json.loads(row["payload_json"]))
except Exception:
continue
return out
def load_all_rows(self) -> List[Dict[str, Any]]:
with self.db.connect() as conn:
rows = conn.execute(
"SELECT payload_json FROM probability_training_snapshots_store ORDER BY id"
).fetchall()
out = []
for row in rows:
try:
out.append(json.loads(row["payload_json"]))
except Exception:
continue
return out
def replace_all(self, rows: List[Dict[str, Any]]) -> int:
count = 0
with self.db.connect() as conn:
conn.execute("DELETE FROM probability_training_snapshots_store")
for payload in rows or []:
if not isinstance(payload, dict):
continue
legacy_top = _top_bucket(payload.get("prob_snapshot"))
shadow_top = _top_bucket(payload.get("shadow_prob_snapshot"))
conn.execute(
"""
INSERT INTO probability_training_snapshots_store (
city, target_date, timestamp, raw_mu, raw_sigma, max_so_far,
peak_status, probability_mode, legacy_top_bucket, shadow_top_bucket, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.get("city"),
payload.get("date"),
payload.get("timestamp"),
payload.get("raw_mu"),
payload.get("raw_sigma"),
payload.get("max_so_far"),
payload.get("peak_status"),
payload.get("probability_mode"),
legacy_top,
shadow_top,
json.dumps(payload, ensure_ascii=False),
),
)
count += 1
conn.commit()
return count
class OpenMeteoCacheRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def replace_payload(self, payload: Dict[str, Any], max_age: int) -> int:
count = 0
now = time.time()
with self.db.connect() as conn:
conn.execute("DELETE FROM open_meteo_cache_store")
for source_kind in ("forecast", "ensemble", "multi_model"):
bucket = payload.get(source_kind) or {}
if not isinstance(bucket, dict):
continue
for cache_key, entry in bucket.items():
if not isinstance(entry, dict):
continue
updated_at = float(entry.get("t") or now)
expires_at = updated_at + max_age
conn.execute(
"""
INSERT INTO open_meteo_cache_store (
source_kind, cache_key, updated_at, expires_at, payload_json
) VALUES (?, ?, ?, ?, ?)
""",
(
source_kind,
cache_key,
updated_at,
expires_at,
json.dumps(entry, ensure_ascii=False),
),
)
count += 1
conn.commit()
return count
def load_payload(self, max_age: int) -> Dict[str, Any]:
now = time.time()
payload: Dict[str, Any] = {
"forecast": {},
"ensemble": {},
"multi_model": {},
"saved_at": now,
}
with self.db.connect() as conn:
rows = conn.execute(
"SELECT source_kind, cache_key, updated_at, payload_json FROM open_meteo_cache_store"
).fetchall()
for row in rows:
updated_at = float(row["updated_at"] or 0)
if now - updated_at >= max(600, max_age):
continue
try:
entry = json.loads(row["payload_json"])
except Exception:
continue
payload.setdefault(str(row["source_kind"]), {})[str(row["cache_key"])] = entry
return payload
def latest_updated_at(self) -> float:
with self.db.connect() as conn:
row = conn.execute(
"SELECT MAX(updated_at) AS max_updated_at FROM open_meteo_cache_store"
).fetchone()
if not row:
return 0.0
try:
return float(row["max_updated_at"] or 0.0)
except Exception:
return 0.0
def _top_bucket(snapshot: Optional[List[Dict[str, Any]]]) -> Optional[int]:
best_value = None
best_prob = -1.0
for row in snapshot or []:
if not isinstance(row, dict):
continue
value = row.get("v")
if value is None:
value = row.get("value")
try:
ivalue = int(value)
except Exception:
continue
prob = row.get("p")
if prob is None:
prob = row.get("probability")
try:
fprob = float(prob)
except Exception:
continue
if fprob > best_prob:
best_prob = fprob
best_value = ivalue
return best_value
def get_runtime_data_dir() -> str:
raw = str(os.getenv("POLYWEATHER_RUNTIME_DATA_DIR") or "").strip()
if raw:
return raw
project_root = Path(__file__).resolve().parents[2]
return str(project_root / "data")
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import threading
from typing import Dict, Iterable, List, Optional, Tuple
LabelTuple = Tuple[Tuple[str, str], ...]
class _MetricsRegistry:
def __init__(self) -> None:
self._lock = threading.Lock()
self._counters: Dict[Tuple[str, LabelTuple], float] = {}
self._gauges: Dict[Tuple[str, LabelTuple], float] = {}
self._histograms: Dict[Tuple[str, LabelTuple], Dict[str, float]] = {}
@staticmethod
def _normalize_labels(labels: Dict[str, object]) -> LabelTuple:
return tuple(
sorted((str(key), str(value)) for key, value in labels.items() if value is not None)
)
def inc_counter(self, name: str, amount: float = 1.0, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
self._counters[key] = self._counters.get(key, 0.0) + amount
def set_gauge(self, name: str, value: float, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
self._gauges[key] = value
def observe(self, name: str, value: float, **labels: object) -> None:
key = (name, self._normalize_labels(labels))
with self._lock:
bucket = self._histograms.setdefault(
key,
{"count": 0.0, "sum": 0.0, "max": 0.0},
)
bucket["count"] += 1.0
bucket["sum"] += value
bucket["max"] = max(bucket["max"], value)
def snapshot(self) -> Dict[str, object]:
with self._lock:
return {
"counters": dict(self._counters),
"gauges": dict(self._gauges),
"histograms": {
key: dict(value) for key, value in self._histograms.items()
},
}
def export_prometheus(self) -> str:
snap = self.snapshot()
lines: List[str] = []
for name, labels, value in _iter_metrics(snap["counters"]):
lines.append(_prom_line(name, value, labels))
for name, labels, value in _iter_metrics(snap["gauges"]):
lines.append(_prom_line(name, value, labels))
for (name, labels), stats in sorted(snap["histograms"].items()):
lines.append(_prom_line(f"{name}_count", stats["count"], labels))
lines.append(_prom_line(f"{name}_sum", stats["sum"], labels))
lines.append(_prom_line(f"{name}_max", stats["max"], labels))
return "\n".join(lines) + ("\n" if lines else "")
def _iter_metrics(entries: Dict[Tuple[str, LabelTuple], float]) -> Iterable[Tuple[str, LabelTuple, float]]:
for (name, labels), value in sorted(entries.items()):
yield name, labels, value
def _prom_line(name: str, value: float, labels: LabelTuple) -> str:
if labels:
def _escape(label_value: str) -> str:
return label_value.replace("\\", "\\\\").replace('"', '\\"')
label_str = ",".join(
f'{key}="{_escape(str(val))}"'
for key, val in labels
)
return f"{name}{{{label_str}}} {value}"
return f"{name} {value}"
METRICS = _MetricsRegistry()
def counter_inc(name: str, amount: float = 1.0, **labels: object) -> None:
METRICS.inc_counter(name, amount=amount, **labels)
def gauge_set(name: str, value: float, **labels: object) -> None:
METRICS.set_gauge(name, value=value, **labels)
def histogram_observe(name: str, value: float, **labels: object) -> None:
METRICS.observe(name, value=value, **labels)
def record_source_call(source: str, operation: str, outcome: str, duration_ms: Optional[float] = None) -> None:
counter_inc(
"polyweather_source_requests_total",
source=source,
operation=operation,
outcome=outcome,
)
if duration_ms is not None:
histogram_observe(
"polyweather_source_request_duration_ms",
duration_ms,
source=source,
operation=operation,
outcome=outcome,
)
def build_metrics_summary() -> Dict[str, object]:
snapshot = METRICS.snapshot()
request_total = 0.0
source_total = 0.0
source_errors = 0.0
for (name, labels), value in snapshot["counters"].items():
if name == "polyweather_http_requests_total":
request_total += value
if name == "polyweather_source_requests_total":
source_total += value
label_map = dict(labels)
if label_map.get("outcome") not in {"success", "cache_hit"}:
source_errors += value
return {
"http_requests_total": int(request_total),
"source_requests_total": int(source_total),
"source_error_total": int(source_errors),
}
def export_prometheus_metrics() -> str:
return METRICS.export_prometheus()
+23
View File
@@ -9,6 +9,12 @@ from typing import Any, Dict, List, Optional, Tuple
from loguru import logger from loguru import logger
from src.database.runtime_state import (
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TelegramAlertStateRepository,
get_state_storage_mode,
)
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
@@ -19,6 +25,7 @@ SEVERITY_RANK = {
"medium": 2, "medium": 2,
"high": 3, "high": 3,
} }
_telegram_state_repo = TelegramAlertStateRepository()
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
@@ -184,7 +191,18 @@ def _state_file() -> str:
def _load_state(path: str) -> Dict[str, Any]: def _load_state(path: str) -> Dict[str, Any]:
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
try:
return _telegram_state_repo.load_state()
except Exception as exc:
logger.error(f"failed to load telegram push state from sqlite: {exc}")
if not os.path.exists(path): if not os.path.exists(path):
if mode == STATE_STORAGE_DUAL:
try:
return _telegram_state_repo.load_state()
except Exception:
return {"last_by_city": {}, "by_signature": {}}
return {"last_by_city": {}, "by_signature": {}} return {"last_by_city": {}, "by_signature": {}}
try: try:
with open(path, "r", encoding="utf-8") as fh: with open(path, "r", encoding="utf-8") as fh:
@@ -199,6 +217,11 @@ def _load_state(path: str) -> Dict[str, Any]:
def _save_state(path: str, state: Dict[str, Any]) -> None: def _save_state(path: str, state: Dict[str, Any]) -> None:
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
_telegram_state_repo.save_state(state)
if mode == STATE_STORAGE_SQLITE:
return
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp" tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh: with open(tmp_path, "w", encoding="utf-8") as fh:
+65
View File
@@ -0,0 +1,65 @@
from src.analysis.probability_rollout import judge_probability_rollout
def test_judge_probability_rollout_holds_on_shadow_brier_regression():
evaluation_report = {
"summary": {
"sample_count": 105,
"delta": {
"crps": -0.09,
"mae": 0.0,
"bucket_hit_rate": 0.0,
},
}
}
shadow_report = {
"summary": {
"samples": 103,
"delta_mae": 0.01,
"delta_bucket_hit_rate": 0.01,
"delta_bucket_brier": 0.29,
},
"by_city": {
"miami": {
"samples": 4,
"delta_mae": 0.24,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.47,
}
},
}
payload = judge_probability_rollout(evaluation_report, shadow_report)
assert payload["decision"] == "hold"
assert payload["ready_for_primary"] is False
assert payload["blocking_reasons"]
assert payload["worst_shadow_regressions"][0]["city"] == "miami"
def test_judge_probability_rollout_promotes_on_clean_metrics():
evaluation_report = {
"summary": {
"sample_count": 120,
"delta": {
"crps": -0.08,
"mae": 0.0,
"bucket_hit_rate": 0.02,
},
}
}
shadow_report = {
"summary": {
"samples": 110,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.01,
"delta_bucket_brier": 0.01,
},
"by_city": {},
}
payload = judge_probability_rollout(evaluation_report, shadow_report)
assert payload["decision"] == "promote"
assert payload["ready_for_primary"] is True
assert payload["blocking_reasons"] == []
@@ -1,9 +1,15 @@
import json import json
from pathlib import Path from pathlib import Path
import pytest
from src.analysis.probability_snapshot_archive import append_probability_snapshot from src.analysis.probability_snapshot_archive import append_probability_snapshot
@pytest.fixture(autouse=True)
def _force_file_mode(monkeypatch):
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "file")
def test_append_probability_snapshot_writes_jsonl(tmp_path: Path): def test_append_probability_snapshot_writes_jsonl(tmp_path: Path):
archive_path = tmp_path / "probability_training_snapshots.jsonl" archive_path = tmp_path / "probability_training_snapshots.jsonl"
+74
View File
@@ -0,0 +1,74 @@
import time
from src.database.runtime_state import (
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
RuntimeStateDB,
TelegramAlertStateRepository,
)
def test_daily_record_repository_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
repo = DailyRecordRepository(RuntimeStateDB(str(tmp_path / 'polyweather.db')))
repo.upsert_record('ankara', '2026-03-20', {'actual_high': 15.2, 'deb_prediction': 14.8, 'mu': 15.0})
data = repo.load_all()
assert data['ankara']['2026-03-20']['actual_high'] == 15.2
def test_telegram_alert_state_repository_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
repo = TelegramAlertStateRepository(RuntimeStateDB(str(tmp_path / 'polyweather.db')))
state = {
'last_by_city': {
'ankara': {
'signature': 'sig-1',
'trigger_key': 'mkt:test',
'severity': 'medium',
'ts': 123,
'active': True,
'evidence': {'x': 1},
}
},
'by_signature': {'sig-1': 123},
}
repo.save_state(state)
loaded = repo.load_state()
assert loaded == state
def test_probability_snapshot_repository_recent_rows(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
repo = ProbabilitySnapshotRepository(RuntimeStateDB(str(tmp_path / 'polyweather.db')))
repo.append_snapshot({
'city': 'ankara',
'date': '2026-03-20',
'timestamp': '2026-03-20T12:00:00Z',
'raw_mu': 15.2,
'raw_sigma': 1.1,
'max_so_far': 14.9,
'peak_status': 'before',
'probability_mode': 'emos_shadow',
'prob_snapshot': [{'v': 15, 'p': 0.6}],
'shadow_prob_snapshot': [{'v': 15, 'p': 0.4}],
})
rows = repo.load_recent_rows('ankara', '2026-03-20', 5)
assert len(rows) == 1
assert rows[0]['raw_mu'] == 15.2
def test_open_meteo_cache_repository_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
repo = OpenMeteoCacheRepository(RuntimeStateDB(str(tmp_path / 'polyweather.db')))
payload = {
'forecast': {'ankara': {'t': time.time(), 'temp': 15}},
'ensemble': {'ankara': {'t': time.time(), 'spread': 1.5}},
'multi_model': {},
'saved_at': 1000,
}
repo.replace_payload(payload, 86400)
loaded = repo.load_payload(86400)
assert loaded['forecast']['ankara']['temp'] == 15
assert loaded['ensemble']['ankara']['spread'] == 1.5
+37
View File
@@ -0,0 +1,37 @@
from fastapi.testclient import TestClient
from web.app import app
client = TestClient(app)
def test_healthz_returns_ok_shape():
response = client.get('/healthz')
assert response.status_code == 200
payload = response.json()
assert payload['status'] in {'ok', 'degraded'}
assert 'db' in payload
assert 'state_storage_mode' in payload
assert 'cities_count' in payload
def test_system_status_returns_summary_shape():
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
assert 'db' in payload
assert 'features' in payload
assert 'integrations' in payload
assert 'cache' in payload
assert 'probability' in payload
assert 'rollout' in payload['probability']
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
assert 'cities_count' in payload
def test_metrics_endpoint_returns_prometheus_payload():
response = client.get('/metrics')
assert response.status_code == 200
assert 'polyweather_http_requests_total' in response.text
+162
View File
@@ -3,6 +3,9 @@ PolyWeather Web Core Context
""" """
import os import os
import sqlite3
import time
from datetime import datetime, timezone
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@@ -16,7 +19,19 @@ from src.data_collection.weather_sources import WeatherDataCollector
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401 from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token
from src.utils.metrics import (
build_metrics_summary,
counter_inc,
gauge_set,
histogram_observe,
)
from src.analysis.probability_calibration import (
DEFAULT_CALIBRATION_FILE,
resolve_probability_engine_mode,
)
from src.analysis.probability_rollout import build_rollout_report
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.database.runtime_state import get_state_storage_mode
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError # noqa: F401 from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError # noqa: F401
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
@@ -65,6 +80,59 @@ SETTLEMENT_SOURCE_LABELS: Dict[str, str] = {
_cache: Dict[str, Dict] = {} _cache: Dict[str, Dict] = {}
CACHE_TTL = 300 CACHE_TTL = 300
CACHE_TTL_ANKARA = 60 CACHE_TTL_ANKARA = 60
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PROBABILITY_EVALUATION_REPORT = os.path.join(
_PROJECT_ROOT,
"artifacts",
"probability_calibration",
"evaluation_report.json",
)
@app.middleware("http")
async def _metrics_middleware(request: Request, call_next):
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = (time.perf_counter() - started) * 1000.0
counter_inc(
"polyweather_http_requests_total",
method=request.method,
path=request.url.path,
status="500",
)
histogram_observe(
"polyweather_http_request_duration_ms",
duration_ms,
method=request.method,
path=request.url.path,
status="500",
)
raise
duration_ms = (time.perf_counter() - started) * 1000.0
status_code = str(response.status_code)
counter_inc(
"polyweather_http_requests_total",
method=request.method,
path=request.url.path,
status=status_code,
)
histogram_observe(
"polyweather_http_request_duration_ms",
duration_ms,
method=request.method,
path=request.url.path,
status=status_code,
)
return response
_PROBABILITY_SHADOW_REPORT = os.path.join(
_PROJECT_ROOT,
"artifacts",
"probability_calibration",
"shadow_report.json",
)
def _env_bool(name: str, default: bool = False) -> bool: def _env_bool(name: str, default: bool = False) -> bool:
@@ -264,3 +332,97 @@ def _sf(v) -> Optional[float]:
def _is_excluded_model_name(model_name: str) -> bool: def _is_excluded_model_name(model_name: str) -> bool:
normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "")
return "meteoblue" in normalized return "meteoblue" in normalized
def _sqlite_health() -> Dict[str, Any]:
try:
with sqlite3.connect(_account_db.db_path) as conn:
conn.execute("SELECT 1").fetchone()
return {"ok": True, "db_path": _account_db.db_path}
except Exception as exc:
return {"ok": False, "db_path": _account_db.db_path, "error": str(exc)}
def _cache_summary() -> Dict[str, Any]:
gauge_set("polyweather_api_cache_entries", len(_cache))
gauge_set(
"polyweather_open_meteo_forecast_cache_entries",
len(getattr(_weather, "_open_meteo_cache", {}) or {}),
)
gauge_set(
"polyweather_open_meteo_ensemble_cache_entries",
len(getattr(_weather, "_ensemble_cache", {}) or {}),
)
gauge_set(
"polyweather_open_meteo_multi_model_cache_entries",
len(getattr(_weather, "_multi_model_cache", {}) or {}),
)
return {
"api_cache_entries": len(_cache),
"open_meteo_forecast_entries": len(getattr(_weather, "_open_meteo_cache", {}) or {}),
"open_meteo_ensemble_entries": len(getattr(_weather, "_ensemble_cache", {}) or {}),
"open_meteo_multi_model_entries": len(getattr(_weather, "_multi_model_cache", {}) or {}),
}
def _feature_flags_summary() -> Dict[str, Any]:
return {
"auth_enabled": bool(SUPABASE_ENTITLEMENT.enabled),
"auth_required": bool(_SUPABASE_AUTH_REQUIRED),
"entitlement_guard_enabled": bool(_ENTITLEMENT_GUARD_ENABLED),
"payment_enabled": bool(getattr(PAYMENT_CHECKOUT, "enabled", False)),
"state_storage_mode": get_state_storage_mode(),
}
def _integration_summary() -> Dict[str, Any]:
weather_cfg = _config.get("weather", {}) if isinstance(_config, dict) else {}
return {
"supabase_configured": bool(SUPABASE_ENTITLEMENT.configured),
"meteoblue_configured": bool(os.getenv("METEOBLUE_API_KEY")),
"telegram_bot_configured": bool((_config.get("telegram", {}) or {}).get("bot_token")),
"walletconnect_configured": bool(os.getenv("NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID")),
"weather_sources": {
"openweather": bool(weather_cfg.get("openweather_api_key")),
"wunderground": bool(weather_cfg.get("wunderground_api_key")),
"visualcrossing": bool(weather_cfg.get("visualcrossing_api_key")),
},
}
def _probability_summary() -> Dict[str, Any]:
rollout = build_rollout_report(
_PROBABILITY_EVALUATION_REPORT,
_PROBABILITY_SHADOW_REPORT,
)
return {
"engine_mode": resolve_probability_engine_mode(),
"calibration_file": os.getenv("POLYWEATHER_PROBABILITY_CALIBRATION_FILE")
or DEFAULT_CALIBRATION_FILE,
"rollout": rollout,
}
def build_health_payload() -> Dict[str, Any]:
db = _sqlite_health()
return {
"status": "ok" if db.get("ok") else "degraded",
"time_utc": datetime.now(timezone.utc).isoformat(),
"db": db,
"state_storage_mode": get_state_storage_mode(),
"cities_count": len(CITIES),
}
def build_system_status_payload() -> Dict[str, Any]:
return {
"status": build_health_payload()["status"],
"time_utc": datetime.now(timezone.utc).isoformat(),
"db": _sqlite_health(),
"features": _feature_flags_summary(),
"integrations": _integration_summary(),
"cache": _cache_summary(),
"metrics": build_metrics_summary(),
"probability": _probability_summary(),
"cities_count": len(CITIES),
}
+25
View File
@@ -4,10 +4,12 @@ import os
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse
from loguru import logger from loguru import logger
from src.analysis.deb_algorithm import load_history from src.analysis.deb_algorithm import load_history
from src.data_collection.city_registry import ALIASES from src.data_collection.city_registry import ALIASES
from src.utils.metrics import export_prometheus_metrics
from web.analysis_service import ( from web.analysis_service import (
_analyze, _analyze,
_build_city_detail_payload, _build_city_detail_payload,
@@ -31,6 +33,8 @@ from web.core import (
_SUPABASE_AUTH_REQUIRED, _SUPABASE_AUTH_REQUIRED,
_assert_entitlement, _assert_entitlement,
_bind_optional_supabase_identity, _bind_optional_supabase_identity,
build_health_payload,
build_system_status_payload,
_require_supabase_identity, _require_supabase_identity,
_resolve_auth_points, _resolve_auth_points,
_resolve_weekly_profile, _resolve_weekly_profile,
@@ -49,6 +53,27 @@ def _normalize_city_or_404(name: str) -> str:
return city return city
@router.get("/healthz")
async def healthz():
payload = build_health_payload()
if payload.get("status") != "ok":
raise HTTPException(status_code=503, detail=payload)
return payload
@router.get("/api/system/status")
async def system_status():
return build_system_status_payload()
@router.get("/metrics", response_class=PlainTextResponse)
async def metrics():
return PlainTextResponse(
export_prometheus_metrics(),
media_type="text/plain; version=0.0.4; charset=utf-8",
)
@router.get("/api/cities") @router.get("/api/cities")
async def list_cities(request: Request): async def list_cities(request: Request):
_assert_entitlement(request) _assert_entitlement(request)