Add ops dashboards for training data and model coverage

This commit is contained in:
2569718930@qq.com
2026-04-03 00:57:19 +08:00
parent 37cd8b8166
commit 781c247952
24 changed files with 3634 additions and 907 deletions
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -42,25 +42,25 @@
"nws" "nws"
], ],
"model_path": "artifacts\\models\\lgbm_daily_high.txt", "model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 29, "sample_count": 54,
"train_count": 17, "train_count": 42,
"validation_count": 12, "validation_count": 12,
"metrics": { "metrics": {
"validation": { "validation": {
"sample_count": 12, "sample_count": 12,
"lgbm_mae": 2.975, "lgbm_mae": 1.349,
"deb_mae": 2.267, "deb_mae": 0.875,
"best_single_mae": 1.167, "best_single_mae": 0.325,
"median_mae": 2.483 "median_mae": 0.758
}, },
"full_sample": { "full_sample": {
"sample_count": 29, "sample_count": 54,
"lgbm_mae": 1.442, "lgbm_mae": 0.691,
"deb_mae": 3.448, "deb_mae": 6.287,
"best_single_mae": 2.486, "best_single_mae": 5.431,
"median_mae": 3.628 "median_mae": 6.265
} }
}, },
"generated_at": "2026-03-29T14:57:35.832414Z", "generated_at": "2026-04-02T16:27:44.816882Z",
"trained_at": "2026-03-29T14:57:35.832414Z" "trained_at": "2026-04-02T16:27:44.816882Z"
} }
+34 -34
View File
@@ -1,20 +1,20 @@
{ {
"version": "emos-20260402151301", "version": "emos-20260402162744",
"trained_at": "2026-04-02T15:13:01.073253+00:00", "trained_at": "2026-04-02T16:27:44.114836+00:00",
"global": { "global": {
"mu": { "mu": {
"intercept": 1.52725527, "intercept": 1.54512641,
"raw_mu_coef": 2.96318139, "raw_mu_coef": 2.96105052,
"deb_coef": -1.54490032, "deb_coef": -1.53260815,
"ens_median_coef": -0.71820793, "ens_median_coef": -0.72849343,
"max_so_far_gap_coef": 10.22802132 "max_so_far_gap_coef": 9.52557689
}, },
"sigma": { "sigma": {
"intercept": 0.67227661, "intercept": 0.67432479,
"raw_sigma_coef": 0.69444674, "raw_sigma_coef": 0.6936692,
"spread_coef": 0.09134583, "spread_coef": 0.08877484,
"peak_flag_coef": -0.58201761, "peak_flag_coef": -0.58374835,
"max_so_far_gap_coef": -0.81482466 "max_so_far_gap_coef": -0.8172477
} }
}, },
"sigma_constraints": { "sigma_constraints": {
@@ -30,60 +30,60 @@
}, },
"blending": { "blending": {
"alpha_mu": 0.0, "alpha_mu": 0.0,
"alpha_sigma": 0.45 "alpha_sigma": 0.05
}, },
"cities": { "cities": {
"ankara": { "ankara": {
"samples": 3, "samples": 3,
"mu_bias": 1.283918, "mu_bias": 1.271844,
"sigma_scale": 1.484056, "sigma_scale": 1.477644,
"confidence": 0.375 "confidence": 0.375
}, },
"hong kong": { "hong kong": {
"samples": 4, "samples": 4,
"mu_bias": 1.329042, "mu_bias": 1.32008,
"sigma_scale": 1.045379, "sigma_scale": 1.04023,
"confidence": 0.5 "confidence": 0.5
}, },
"milan": { "milan": {
"samples": 3, "samples": 3,
"mu_bias": -3.932299, "mu_bias": -3.935178,
"sigma_scale": 2.0, "sigma_scale": 2.0,
"confidence": 0.375 "confidence": 0.375
}, },
"shanghai": { "shanghai": {
"samples": 3, "samples": 3,
"mu_bias": 1.827618, "mu_bias": 1.810495,
"sigma_scale": 2.0, "sigma_scale": 2.0,
"confidence": 0.375 "confidence": 0.375
}, },
"taipei": { "taipei": {
"samples": 3, "samples": 3,
"mu_bias": 3.387896, "mu_bias": 3.577828,
"sigma_scale": 2.0, "sigma_scale": 2.0,
"confidence": 0.375 "confidence": 0.375
}, },
"warsaw": { "warsaw": {
"samples": 3, "samples": 3,
"mu_bias": -0.614031, "mu_bias": -0.625333,
"sigma_scale": 1.252706, "sigma_scale": 1.25968,
"confidence": 0.375 "confidence": 0.375
} }
}, },
"metrics": { "metrics": {
"sample_count": 54, "sample_count": 54,
"mean_crps": 3.78558, "mean_crps": 3.792563,
"legacy_mean_crps": 4.326547, "legacy_mean_crps": 4.308029,
"legacy_mean_mae": 4.528889, "legacy_mean_mae": 4.51037,
"legacy_bucket_hit_rate": 0.518519, "legacy_bucket_hit_rate": 0.537037,
"legacy_bucket_brier": 0.83496, "legacy_bucket_brier": 0.833294,
"selected_mean_crps": 4.239815, "selected_mean_crps": 4.249828,
"selected_mean_mae": 4.528889, "selected_mean_mae": 4.51037,
"selected_bucket_hit_rate": 0.537037, "selected_bucket_hit_rate": 0.555556,
"selected_bucket_brier": 0.853485, "selected_bucket_brier": 0.831872,
"selected_score": 6.027262, "selected_score": 5.991436,
"legacy_score": 6.127879, "legacy_score": 6.078481,
"filled_actual_from_history": 2, "filled_actual_from_history": 0,
"settlement_history_city_count": 30 "settlement_history_city_count": 30
}, },
"source": "artifacts\\probability_calibration\\default.json" "source": "artifacts\\probability_calibration\\default.json"
@@ -3,17 +3,17 @@
"sample_count": 54, "sample_count": 54,
"filled_actual_from_history": 2, "filled_actual_from_history": 2,
"legacy": { "legacy": {
"mean_crps": 4.287017, "mean_crps": 4.300621,
"mean_mae": 4.488148, "mean_mae": 4.502963,
"bucket_hit_rate": 0.537037 "bucket_hit_rate": 0.537037
}, },
"emos": { "emos": {
"mean_crps": 4.201116, "mean_crps": 4.213889,
"mean_mae": 4.488148, "mean_mae": 4.502963,
"bucket_hit_rate": 0.537037 "bucket_hit_rate": 0.537037
}, },
"delta": { "delta": {
"crps": -0.085901, "crps": -0.086732,
"mae": 0.0, "mae": 0.0,
"bucket_hit_rate": 0.0 "bucket_hit_rate": 0.0
} }
@@ -183,12 +183,12 @@
}, },
"shenzhen": { "shenzhen": {
"samples": 1, "samples": 1,
"legacy_mean_crps": 0.063696, "legacy_mean_crps": 0.798351,
"emos_mean_crps": 0.073016, "emos_mean_crps": 0.762787,
"legacy_mean_mae": 0.1, "legacy_mean_mae": 0.9,
"emos_mean_mae": 0.1, "emos_mean_mae": 0.9,
"legacy_bucket_hit_rate": 1.0, "legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 1.0 "emos_bucket_hit_rate": 0.0
}, },
"singapore": { "singapore": {
"samples": 2, "samples": 2,
@@ -205,8 +205,8 @@
"emos_mean_crps": 0.472738, "emos_mean_crps": 0.472738,
"legacy_mean_mae": 0.1, "legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1, "emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 0.666667, "legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.666667 "emos_bucket_hit_rate": 1.0
}, },
"tel aviv": { "tel aviv": {
"samples": 2, "samples": 2,
@@ -17,55 +17,57 @@
"max_delta_bucket_brier_observe": 0.15 "max_delta_bucket_brier_observe": 0.15
}, },
"evaluation": { "evaluation": {
"sample_count": 105, "sample_count": 54,
"delta_crps": -0.093663, "delta_crps": -0.086732,
"delta_mae": 0.0, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0 "delta_bucket_hit_rate": 0.0
}, },
"shadow": { "shadow": {
"sample_count": 103, "sample_count": 48,
"delta_mae": 0.012708, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.009709, "delta_bucket_hit_rate": 0.041666,
"delta_bucket_brier": 0.293835 "delta_bucket_brier": 0.123252
}, },
"blocking_reasons": [ "blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835" "离线评估样本不足:54 < 80",
"shadow 样本不足:48 < 50",
"shadow bucket brier 退化超限:delta=0.123252"
], ],
"worst_shadow_regressions": [ "worst_shadow_regressions": [
{ {
"city": "dallas", "city": "dallas",
"samples": 4, "samples": 1,
"delta_mae": 0.114807, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678 "delta_bucket_brier": 0.792585
}, },
{ {
"city": "chicago", "city": "chicago",
"samples": 4, "samples": 1,
"delta_mae": 0.075265, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156 "delta_bucket_brier": 0.791878
}, },
{ {
"city": "seattle", "city": "seattle",
"samples": 4, "samples": 1,
"delta_mae": 0.11262, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003 "delta_bucket_brier": 0.61609
}, },
{ {
"city": "atlanta", "city": "wellington",
"samples": 4, "samples": 2,
"delta_mae": 0.293028, "delta_mae": 0.0,
"delta_bucket_hit_rate": -0.25, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.601425 "delta_bucket_brier": 0.509203
}, },
{ {
"city": "miami", "city": "tel aviv",
"samples": 4, "samples": 2,
"delta_mae": 0.241559, "delta_mae": 0.0,
"delta_bucket_hit_rate": -0.5, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.478245 "delta_bucket_brier": 0.439879
} }
] ]
} }
@@ -1,16 +1,16 @@
{ {
"generated_at": "2026-04-02T15:13:02.010825Z", "generated_at": "2026-04-02T16:23:24.376528Z",
"summary": { "summary": {
"samples": 48, "samples": 48,
"legacy_mean_mae": 3.04125, "legacy_mean_mae": 3.04125,
"shadow_mean_mae": 3.04125, "shadow_mean_mae": 3.04125,
"legacy_bucket_hit_rate": 0.479167, "legacy_bucket_hit_rate": 0.5,
"shadow_bucket_hit_rate": 0.520833, "shadow_bucket_hit_rate": 0.5,
"legacy_bucket_brier": 0.688077, "legacy_bucket_brier": 0.68666,
"shadow_bucket_brier": 0.811329, "shadow_bucket_brier": 0.814079,
"delta_mae": 0.0, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.041666, "delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.123252 "delta_bucket_brier": 0.127419
}, },
"by_city": { "by_city": {
"ankara": { "ankara": {
@@ -245,13 +245,13 @@
"samples": 3, "samples": 3,
"legacy_mean_mae": 0.1, "legacy_mean_mae": 0.1,
"shadow_mean_mae": 0.1, "shadow_mean_mae": 0.1,
"legacy_bucket_hit_rate": 0.666667, "legacy_bucket_hit_rate": 1.0,
"shadow_bucket_hit_rate": 0.666667, "shadow_bucket_hit_rate": 0.333333,
"legacy_bucket_brier": 0.217927, "legacy_bucket_brier": 0.195261,
"shadow_bucket_brier": 0.640998, "shadow_bucket_brier": 0.684998,
"delta_mae": 0.0, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0, "delta_bucket_hit_rate": -0.666667,
"delta_bucket_brier": 0.423071 "delta_bucket_brier": 0.489737
}, },
"tel aviv": { "tel aviv": {
"samples": 2, "samples": 2,
@@ -343,13 +343,13 @@
"samples": 19, "samples": 19,
"legacy_mean_mae": 4.299474, "legacy_mean_mae": 4.299474,
"shadow_mean_mae": 4.299474, "shadow_mean_mae": 4.299474,
"legacy_bucket_hit_rate": 0.315789, "legacy_bucket_hit_rate": 0.368421,
"shadow_bucket_hit_rate": 0.473684, "shadow_bucket_hit_rate": 0.421053,
"legacy_bucket_brier": 0.85177, "legacy_bucket_brier": 0.848191,
"shadow_bucket_brier": 0.928731, "shadow_bucket_brier": 0.935678,
"delta_mae": 0.0, "delta_mae": 0.0,
"delta_bucket_hit_rate": 0.157895, "delta_bucket_hit_rate": 0.052632,
"delta_bucket_brier": 0.076961 "delta_bucket_brier": 0.087487
} }
}, },
"recent_observations": [ "recent_observations": [
@@ -416,8 +416,8 @@
{ {
"city": "taipei", "city": "taipei",
"date": "2026-03-19", "date": "2026-03-19",
"actual_high": 21.4, "actual_high": 22.0,
"actual_bucket": 21, "actual_bucket": 22,
"legacy_mu": 21.7, "legacy_mu": 21.7,
"shadow_mu": 21.7, "shadow_mu": 21.7,
"legacy_top_bucket": 22, "legacy_top_bucket": 22,
@@ -8,7 +8,7 @@
"city": "shenzhen", "city": "shenzhen",
"date": "2026-03-25", "date": "2026-03-25",
"timestamp": "2026-03-25T08:57:11.783182+00:00", "timestamp": "2026-03-25T08:57:11.783182+00:00",
"actual_high": 29.0, "actual_high": 28.0,
"raw_mu": 26.7, "raw_mu": 26.7,
"raw_sigma": 0.18016764322916676, "raw_sigma": 0.18016764322916676,
"deb_prediction": 28.1, "deb_prediction": 28.1,
@@ -16,7 +16,12 @@
"ensemble_spread": 0.5078125000000002, "ensemble_spread": 0.5078125000000002,
"max_so_far_gap": 1.4000000000000021, "max_so_far_gap": 1.4000000000000021,
"peak_flag": 1.0, "peak_flag": 1.0,
"sample_source": "snapshot" "sample_source": "snapshot",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "ankara", "city": "ankara",
@@ -29,7 +34,12 @@
"ensemble_spread": 1.2000000000000002, "ensemble_spread": 1.2000000000000002,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "ankara", "city": "ankara",
@@ -42,7 +52,12 @@
"ensemble_spread": 2.05, "ensemble_spread": 2.05,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "ankara", "city": "ankara",
@@ -55,7 +70,12 @@
"ensemble_spread": 0.9000000000000004, "ensemble_spread": 0.9000000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "atlanta", "city": "atlanta",
@@ -68,7 +88,12 @@
"ensemble_spread": 4.0, "ensemble_spread": 4.0,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "atlanta", "city": "atlanta",
@@ -81,7 +106,12 @@
"ensemble_spread": 1.5500000000000007, "ensemble_spread": 1.5500000000000007,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "buenos aires", "city": "buenos aires",
@@ -94,7 +124,12 @@
"ensemble_spread": 2.0, "ensemble_spread": 2.0,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "buenos aires", "city": "buenos aires",
@@ -107,7 +142,12 @@
"ensemble_spread": 2.0999999999999996, "ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "chicago", "city": "chicago",
@@ -120,7 +160,12 @@
"ensemble_spread": 5.350000000000001, "ensemble_spread": 5.350000000000001,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "dallas", "city": "dallas",
@@ -133,7 +178,12 @@
"ensemble_spread": 9.299999999999997, "ensemble_spread": 9.299999999999997,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "hong kong", "city": "hong kong",
@@ -146,7 +196,12 @@
"ensemble_spread": 1.9000000000000004, "ensemble_spread": 1.9000000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "hong kong", "city": "hong kong",
@@ -159,7 +214,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "hong kong", "city": "hong kong",
@@ -172,7 +232,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "hong kong", "city": "hong kong",
@@ -185,7 +250,12 @@
"ensemble_spread": 1.6999999999999993, "ensemble_spread": 1.6999999999999993,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "london", "city": "london",
@@ -198,7 +268,12 @@
"ensemble_spread": 1.299999999999999, "ensemble_spread": 1.299999999999999,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "london", "city": "london",
@@ -211,7 +286,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "lucknow", "city": "lucknow",
@@ -224,7 +304,12 @@
"ensemble_spread": 1.3000000000000007, "ensemble_spread": 1.3000000000000007,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "lucknow", "city": "lucknow",
@@ -237,7 +322,12 @@
"ensemble_spread": 1.75, "ensemble_spread": 1.75,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "madrid", "city": "madrid",
@@ -250,7 +340,12 @@
"ensemble_spread": 1.8499999999999996, "ensemble_spread": 1.8499999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "madrid", "city": "madrid",
@@ -263,7 +358,12 @@
"ensemble_spread": 1.8999999999999995, "ensemble_spread": 1.8999999999999995,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "miami", "city": "miami",
@@ -276,7 +376,12 @@
"ensemble_spread": 2.8999999999999986, "ensemble_spread": 2.8999999999999986,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "miami", "city": "miami",
@@ -289,7 +394,12 @@
"ensemble_spread": 2.5500000000000043, "ensemble_spread": 2.5500000000000043,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "milan", "city": "milan",
@@ -302,7 +412,12 @@
"ensemble_spread": 9.1, "ensemble_spread": 9.1,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "milan", "city": "milan",
@@ -315,7 +430,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "milan", "city": "milan",
@@ -328,7 +448,12 @@
"ensemble_spread": 1.25, "ensemble_spread": 1.25,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "munich", "city": "munich",
@@ -341,7 +466,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "munich", "city": "munich",
@@ -354,7 +484,12 @@
"ensemble_spread": 1.3000000000000007, "ensemble_spread": 1.3000000000000007,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "new york", "city": "new york",
@@ -367,7 +502,12 @@
"ensemble_spread": 2.25, "ensemble_spread": 2.25,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "paris", "city": "paris",
@@ -380,7 +520,12 @@
"ensemble_spread": 0.9000000000000004, "ensemble_spread": 0.9000000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "paris", "city": "paris",
@@ -393,7 +538,12 @@
"ensemble_spread": 0.6, "ensemble_spread": 0.6,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "sao paulo", "city": "sao paulo",
@@ -406,7 +556,12 @@
"ensemble_spread": 2.450000000000001, "ensemble_spread": 2.450000000000001,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "sao paulo", "city": "sao paulo",
@@ -419,7 +574,12 @@
"ensemble_spread": 1.200000000000001, "ensemble_spread": 1.200000000000001,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "seattle", "city": "seattle",
@@ -432,7 +592,12 @@
"ensemble_spread": 1.3500000000000014, "ensemble_spread": 1.3500000000000014,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "seoul", "city": "seoul",
@@ -445,7 +610,12 @@
"ensemble_spread": 0.7999999999999998, "ensemble_spread": 0.7999999999999998,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "seoul", "city": "seoul",
@@ -458,7 +628,12 @@
"ensemble_spread": 1.7999999999999998, "ensemble_spread": 1.7999999999999998,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "shanghai", "city": "shanghai",
@@ -471,7 +646,12 @@
"ensemble_spread": 1.1500000000000004, "ensemble_spread": 1.1500000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "shanghai", "city": "shanghai",
@@ -484,7 +664,12 @@
"ensemble_spread": 0.7999999999999998, "ensemble_spread": 0.7999999999999998,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "shanghai", "city": "shanghai",
@@ -497,7 +682,12 @@
"ensemble_spread": 1.6999999999999993, "ensemble_spread": 1.6999999999999993,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "singapore", "city": "singapore",
@@ -510,7 +700,12 @@
"ensemble_spread": 0.9500000000000011, "ensemble_spread": 0.9500000000000011,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "singapore", "city": "singapore",
@@ -523,7 +718,12 @@
"ensemble_spread": 1.3499999999999996, "ensemble_spread": 1.3499999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "taipei", "city": "taipei",
@@ -536,7 +736,12 @@
"ensemble_spread": 2.25, "ensemble_spread": 2.25,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "taipei", "city": "taipei",
@@ -549,12 +754,17 @@
"ensemble_spread": 1.0500000000000007, "ensemble_spread": 1.0500000000000007,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "taipei", "city": "taipei",
"date": "2026-03-19", "date": "2026-03-19",
"actual_high": 21.4, "actual_high": 22.0,
"raw_mu": 21.7, "raw_mu": 21.7,
"raw_sigma": 1.1500000000000004, "raw_sigma": 1.1500000000000004,
"deb_prediction": 21.5, "deb_prediction": 21.5,
@@ -562,7 +772,12 @@
"ensemble_spread": 1.1500000000000004, "ensemble_spread": 1.1500000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "tel aviv", "city": "tel aviv",
@@ -575,7 +790,12 @@
"ensemble_spread": 2.1500000000000004, "ensemble_spread": 2.1500000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "tel aviv", "city": "tel aviv",
@@ -588,7 +808,12 @@
"ensemble_spread": 1.5, "ensemble_spread": 1.5,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "tokyo", "city": "tokyo",
@@ -601,7 +826,12 @@
"ensemble_spread": 1.5499999999999998, "ensemble_spread": 1.5499999999999998,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "tokyo", "city": "tokyo",
@@ -614,7 +844,12 @@
"ensemble_spread": 2.0999999999999996, "ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "toronto", "city": "toronto",
@@ -627,7 +862,12 @@
"ensemble_spread": 0.8, "ensemble_spread": 0.8,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "toronto", "city": "toronto",
@@ -640,7 +880,12 @@
"ensemble_spread": 2.1500000000000004, "ensemble_spread": 2.1500000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "warsaw", "city": "warsaw",
@@ -653,7 +898,12 @@
"ensemble_spread": 0.6499999999999995, "ensemble_spread": 0.6499999999999995,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "warsaw", "city": "warsaw",
@@ -666,7 +916,12 @@
"ensemble_spread": 1.4000000000000004, "ensemble_spread": 1.4000000000000004,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "warsaw", "city": "warsaw",
@@ -679,7 +934,12 @@
"ensemble_spread": 1.5999999999999996, "ensemble_spread": 1.5999999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "wellington", "city": "wellington",
@@ -692,7 +952,12 @@
"ensemble_spread": 0.9500000000000011, "ensemble_spread": 0.9500000000000011,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
}, },
{ {
"city": "wellington", "city": "wellington",
@@ -705,7 +970,12 @@
"ensemble_spread": 2.0999999999999996, "ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null, "max_so_far_gap": null,
"peak_flag": 0.0, "peak_flag": 0.0,
"sample_source": "daily_record" "sample_source": "daily_record",
"settlement_source": null,
"settlement_station_code": null,
"truth_version": null,
"truth_updated_by": null,
"truth_updated_at": null
} }
] ]
} }
+431
View File
@@ -31,6 +31,96 @@ type SystemStatusPayload = {
}; };
}; };
integrations?: Record<string, unknown>; integrations?: Record<string, unknown>;
training_data?: {
db_ok?: boolean;
db_path?: string;
error?: string;
truth_records?: {
row_count?: number;
cities_count?: number;
min_date?: string | null;
max_date?: string | null;
source_counts?: Record<string, number>;
};
truth_revisions?: {
row_count?: number;
last_updated_at?: number | null;
};
training_features?: {
row_count?: number;
cities_count?: number;
min_date?: string | null;
max_date?: string | null;
};
city_coverage?: {
total_cities?: number;
with_truth_rows?: number;
with_feature_rows?: number;
highlighted?: Array<{
city: string;
name?: string;
settlement_source?: string;
settlement_station_code?: string;
truth_rows?: number;
feature_rows?: number;
truth_min_date?: string | null;
truth_max_date?: string | null;
feature_min_date?: string | null;
feature_max_date?: string | null;
}>;
top_gaps?: Array<{
city: string;
name?: string;
settlement_source?: string;
settlement_station_code?: string;
truth_rows?: number;
feature_rows?: number;
truth_min_date?: string | null;
truth_max_date?: string | null;
feature_min_date?: string | null;
feature_max_date?: string | null;
}>;
};
model_city_coverage?: {
cities_with_emos_training?: number;
cities_with_lgbm_candidates?: number;
weakest?: Array<{
city: string;
name?: string;
settlement_source?: string;
truth_rows?: number;
feature_rows?: number;
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_evaluation_samples?: number;
lgbm_candidate_rows?: number;
}>;
strongest?: Array<{
city: string;
name?: string;
settlement_source?: string;
truth_rows?: number;
feature_rows?: number;
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_evaluation_samples?: number;
lgbm_candidate_rows?: number;
}>;
};
artifacts?: {
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_daily_record_samples?: number;
emos_evaluation_samples?: number;
emos_shadow_samples?: number;
emos_delta_crps?: number | null;
lgbm_sample_count?: number;
lgbm_train_count?: number;
lgbm_validation_count?: number;
lgbm_validation_mae?: number | null;
lgbm_validation_deb_mae?: number | null;
};
};
}; };
type PaymentRuntimePayload = { type PaymentRuntimePayload = {
@@ -140,6 +230,16 @@ function formatDateTime(value?: string | null) {
return date.toLocaleString("zh-CN", { hour12: false }); return date.toLocaleString("zh-CN", { hour12: false });
} }
function formatUnixDateTime(value?: number | null) {
if (!value) return "-";
return formatDateTime(new Date(value * 1000).toISOString());
}
function formatMetric(value?: number | null, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
return Number(value).toFixed(digits);
}
function maskUrl(value?: string | null) { function maskUrl(value?: string | null) {
if (!value) return "-"; if (!value) return "-";
if (value.length <= 40) return value; if (value.length <= 40) return value;
@@ -313,6 +413,36 @@ export function OpsDashboard() {
}, [incidentReasonFilter, load, loadPaymentIncidents]); }, [incidentReasonFilter, load, loadPaymentIncidents]);
const rolloutDecision = status?.probability?.rollout?.decision; const rolloutDecision = status?.probability?.rollout?.decision;
const trainingData = status?.training_data;
const truthRecords = trainingData?.truth_records;
const truthRevisions = trainingData?.truth_revisions;
const trainingFeatures = trainingData?.training_features;
const cityCoverage = trainingData?.city_coverage;
const modelCityCoverage = trainingData?.model_city_coverage;
const trainingArtifacts = trainingData?.artifacts;
const truthSources = Object.entries(truthRecords?.source_counts || {});
const cityCoverageRows = useMemo(() => {
const modelIndex = new Map(
[...(modelCityCoverage?.strongest || []), ...(modelCityCoverage?.weakest || [])].map((entry) => [entry.city, entry]),
);
const rows: Array<Record<string, unknown>> = [];
for (const entry of [...(cityCoverage?.top_gaps || []), ...(cityCoverage?.highlighted || [])]) {
if (!entry || rows.some((row) => row.city === entry.city)) continue;
const modelEntry = modelIndex.get(entry.city);
rows.push({
...entry,
emos_training_samples: modelEntry?.emos_training_samples ?? 0,
emos_evaluation_samples: modelEntry?.emos_evaluation_samples ?? 0,
lgbm_candidate_rows: modelEntry?.lgbm_candidate_rows ?? entry.feature_rows ?? 0,
});
}
rows.sort((a, b) => {
const aScore = Number(a.truth_rows || 0) + Number(a.feature_rows || 0) + Number(a.emos_training_samples || 0);
const bScore = Number(b.truth_rows || 0) + Number(b.feature_rows || 0) + Number(b.emos_training_samples || 0);
return aScore - bScore || String(a.city || "").localeCompare(String(b.city || ""));
});
return rows;
}, [cityCoverage?.highlighted, cityCoverage?.top_gaps, modelCityCoverage?.strongest, modelCityCoverage?.weakest]);
const funnelSteps = useMemo(() => { const funnelSteps = useMemo(() => {
const events = analyticsFunnel?.events || {}; const events = analyticsFunnel?.events || {};
@@ -682,6 +812,307 @@ export function OpsDashboard() {
</Card> </Card>
</section> </section>
<section className="grid gap-4 xl:grid-cols-[1.2fr_1.2fr_1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 14 </CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="truth rows" value={String(truthRecords?.row_count ?? 0)} mono />
<MobileField label="cities" value={String(truthRecords?.cities_count ?? 0)} mono />
<MobileField label="min date" value={truthRecords?.min_date || "-"} mono />
<MobileField label="max date" value={truthRecords?.max_date || "-"} mono />
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500"></div>
<div className="mt-3 flex flex-wrap gap-2">
{truthSources.length ? (
truthSources.map(([source, count]) => (
<Badge key={source} variant="secondary">
{source}: {count}
</Badge>
))
) : (
<span className="text-xs text-slate-500"></span>
)}
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex justify-between gap-3">
<span>revision rows</span>
<span>{truthRevisions?.row_count ?? 0}</span>
</div>
<div className="mt-2 flex justify-between gap-3 text-xs text-slate-500">
<span>last revision</span>
<span>{formatUnixDateTime(truthRevisions?.last_updated_at)}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="feature rows" value={String(trainingFeatures?.row_count ?? 0)} mono />
<MobileField label="cities" value={String(trainingFeatures?.cities_count ?? 0)} mono />
<MobileField label="min date" value={trainingFeatures?.min_date || "-"} mono />
<MobileField label="max date" value={trainingFeatures?.max_date || "-"} mono />
</div>
<div className="rounded-2xl border border-dashed border-slate-700 bg-slate-950/40 px-3 py-2 text-xs text-slate-500">
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3 text-xs text-slate-500">
DB: {trainingData?.db_ok ? "ok" : "error"} · {trainingData?.db_path || "-"}
{trainingData?.error ? <div className="mt-2 text-rose-300">{trainingData.error}</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS / LGBM </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">EMOS</div>
<div className="mt-2 space-y-2">
<div className="flex justify-between gap-3"><span>training samples</span><span>{trainingArtifacts?.emos_training_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>evaluation samples</span><span>{trainingArtifacts?.emos_evaluation_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>shadow samples</span><span>{trainingArtifacts?.emos_shadow_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>delta CRPS</span><span>{formatMetric(trainingArtifacts?.emos_delta_crps)}</span></div>
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">LGBM</div>
<div className="mt-2 space-y-2">
<div className="flex justify-between gap-3"><span>sample count</span><span>{trainingArtifacts?.lgbm_sample_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>train / val</span><span>{trainingArtifacts?.lgbm_train_count ?? 0} / {trainingArtifacts?.lgbm_validation_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>val mae</span><span>{formatMetric(trainingArtifacts?.lgbm_validation_mae)}</span></div>
<div className="flex justify-between gap-3"><span>DEB val mae</span><span>{formatMetric(trainingArtifacts?.lgbm_validation_deb_mae)}</span></div>
</div>
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle>Wunderground </CardTitle>
<CardDescription> WU </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">truth cities {cityCoverage?.with_truth_rows ?? 0}/{cityCoverage?.total_cities ?? 0}</Badge>
<Badge variant="secondary">feature cities {cityCoverage?.with_feature_rows ?? 0}/{cityCoverage?.total_cities ?? 0}</Badge>
</div>
{(cityCoverage?.highlighted || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<Badge variant="secondary">{entry.settlement_source || "-"}</Badge>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<MobileField label="station" value={entry.settlement_station_code || "-"} mono />
<MobileField label="truth rows" value={String(entry.truth_rows ?? 0)} mono />
<MobileField label="truth max" value={entry.truth_max_date || "-"} mono />
<MobileField label="feature rows" value={String(entry.feature_rows ?? 0)} mono />
</div>
</div>
))}
{!(cityCoverage?.highlighted || []).length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"> Wunderground </div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> truth / feature </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
{(cityCoverage?.top_gaps || []).length ? (
<div className="space-y-2">
{(cityCoverage?.top_gaps || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">
{entry.city} · {entry.settlement_source || "-"} · {entry.settlement_station_code || "-"}
</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant={entry.truth_rows ? "success" : "warning"}>truth {entry.truth_rows ?? 0}</Badge>
<Badge variant={entry.feature_rows ? "success" : "warning"}>feature {entry.feature_rows ?? 0}</Badge>
</div>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-xs text-slate-500">
<div>truth range: {entry.truth_min_date || "-"} {entry.truth_max_date || "-"}</div>
<div>feature range: {entry.feature_min_date || "-"} {entry.feature_max_date || "-"}</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
)}
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">EMOS cities {modelCityCoverage?.cities_with_emos_training ?? 0}</Badge>
<Badge variant="secondary">LGBM candidate cities {modelCityCoverage?.cities_with_lgbm_candidates ?? 0}</Badge>
</div>
<div className="space-y-2">
{(modelCityCoverage?.strongest || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">{entry.city} · {entry.settlement_source || "-"}</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant="success">EMOS {entry.emos_training_samples ?? 0}</Badge>
<Badge variant="success">LGBM {entry.lgbm_candidate_rows ?? 0}</Badge>
</div>
</div>
</div>
))}
{!(modelCityCoverage?.strongest || []).length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS/LGBM </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
{(modelCityCoverage?.weakest || []).length ? (
<div className="space-y-2">
{(modelCityCoverage?.weakest || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">{entry.city} · {entry.settlement_source || "-"}</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant={entry.truth_rows ? "secondary" : "warning"}>truth {entry.truth_rows ?? 0}</Badge>
<Badge variant={entry.emos_training_samples ? "secondary" : "warning"}>EMOS {entry.emos_training_samples ?? 0}</Badge>
<Badge variant={entry.lgbm_candidate_rows ? "secondary" : "warning"}>LGBM {entry.lgbm_candidate_rows ?? 0}</Badge>
</div>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-3 text-xs text-slate-500">
<div>feature rows: {entry.feature_rows ?? 0}</div>
<div>snapshot rows: {entry.emos_snapshot_samples ?? 0}</div>
<div>eval rows: {entry.emos_evaluation_samples ?? 0}</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
)}
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> truth / feature / EMOS / LGBM DEB</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 md:hidden">
{cityCoverageRows.map((entry) => (
<div key={String(entry.city)} className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{String(entry.name || entry.city || "-")}</div>
<div className="mt-1 text-xs text-slate-500">
{String(entry.city || "-")} · {String(entry.settlement_source || "-")} · {String(entry.settlement_station_code || "-")}
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<MobileField label="truth" value={String(entry.truth_rows || 0)} mono />
<MobileField label="feature" value={String(entry.feature_rows || 0)} mono />
<MobileField label="EMOS" value={String(entry.emos_training_samples || 0)} mono />
<MobileField label="LGBM" value={String(entry.lgbm_candidate_rows || 0)} mono />
</div>
</div>
))}
</div>
<div className="hidden overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="px-4 py-3">City</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Station</th>
<th className="px-4 py-3">Truth</th>
<th className="px-4 py-3">Feature</th>
<th className="px-4 py-3">EMOS</th>
<th className="px-4 py-3">LGBM</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{cityCoverageRows.map((entry) => (
<tr key={String(entry.city)}>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{String(entry.name || entry.city || "-")}</div>
<div className="mt-1 text-xs text-slate-500">{String(entry.city || "-")}</div>
</td>
<td className="px-4 py-3">{String(entry.settlement_source || "-")}</td>
<td className="px-4 py-3">{String(entry.settlement_station_code || "-")}</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.truth_rows || 0) > 0 ? "success" : "warning"}>{String(entry.truth_rows || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.feature_rows || 0) > 0 ? "success" : "warning"}>{String(entry.feature_rows || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.emos_training_samples || 0) > 0 ? "success" : "warning"}>{String(entry.emos_training_samples || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.lgbm_candidate_rows || 0) > 0 ? "success" : "warning"}>{String(entry.lgbm_candidate_rows || 0)}</Badge>
</td>
</tr>
))}
{!cityCoverageRows.length ? (
<tr>
<td className="px-4 py-4 text-slate-500" colSpan={7}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -25,12 +25,12 @@ def _target_dates(city_info: dict, lookback_days: int) -> list[str]:
def _is_metar_city(city_info: dict) -> bool: def _is_metar_city(city_info: dict) -> bool:
source = str(city_info.get("settlement_source") or "metar").strip().lower() source = str(city_info.get("settlement_source") or "metar").strip().lower()
return source == "metar" return source in {"metar", "hko", "noaa", "wunderground"}
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Seed recent daily_records rows and backfill actual_high from aviationweather METAR history." description="Seed recent runtime daily_records rows and backfill actual_high from the city's settlement source."
) )
parser.add_argument( parser.add_argument(
"--cities", "--cities",
+76 -11
View File
@@ -15,9 +15,12 @@ from src.analysis.probability_calibration import ( # noqa: E402
) )
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 from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
ProbabilitySnapshotRepository, ProbabilitySnapshotRepository,
STATE_STORAGE_FILE, STATE_STORAGE_FILE,
STATE_STORAGE_SQLITE, STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode, get_state_storage_mode,
) )
@@ -56,12 +59,34 @@ def _default_snapshot_arg():
def _load_history_with_fallback(path): def _load_history_with_fallback(path):
if not path:
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return DailyRecordRepository().load_all()
return {}
data = load_history(path) data = load_history(path)
if data: if data:
return data return data
return _load_json_if_exists(path) return _load_json_if_exists(path)
def _load_truth_history():
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
return {}
try:
return TruthRecordRepository().load_all()
except Exception:
return {}
def _load_training_feature_history():
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
return {}
try:
return TrainingFeatureRecordRepository().load_all()
except Exception:
return {}
def _load_snapshot_rows(path): def _load_snapshot_rows(path):
if get_state_storage_mode() == STATE_STORAGE_SQLITE: if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return ProbabilitySnapshotRepository().load_all_rows() return ProbabilitySnapshotRepository().load_all_rows()
@@ -82,18 +107,28 @@ def _load_snapshot_rows(path):
return rows return rows
def _actual_high_for(history, settlement_history, city, date_str): def _actual_high_for(history, truth_history, settlement_history, city, date_str):
city_rows = (history or {}).get(city) or {} city_rows = (history or {}).get(city) or {}
record = city_rows.get(date_str) or {} record = city_rows.get(date_str) or {}
actual_high = _sf(record.get("actual_high")) if isinstance(record, dict) else None actual_high = _sf(record.get("actual_high")) if isinstance(record, dict) else None
truth_record = ((truth_history.get(city) or {}).get(date_str) or {})
if actual_high is None and isinstance(truth_record, dict):
actual_high = _sf(truth_record.get("actual_high"))
filled = False filled = False
if actual_high is None: if actual_high is None:
actual_high = _sf(((settlement_history.get(city) or {}).get(date_str) or {}).get("max_temp")) actual_high = _sf(((settlement_history.get(city) or {}).get(date_str) or {}).get("max_temp"))
filled = actual_high is not None filled = actual_high is not None
return actual_high, filled metadata = {
"settlement_source": truth_record.get("settlement_source"),
"settlement_station_code": truth_record.get("settlement_station_code"),
"truth_version": truth_record.get("truth_version"),
"truth_updated_by": truth_record.get("updated_by"),
"truth_updated_at": truth_record.get("truth_updated_at"),
}
return actual_high, filled, metadata
def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None): def _extract_snapshot_samples(history, truth_history=None, snapshot_rows=None, settlement_history=None):
samples = [] samples = []
filled_actual_from_history = 0 filled_actual_from_history = 0
today = datetime.utcnow().strftime("%Y-%m-%d") today = datetime.utcnow().strftime("%Y-%m-%d")
@@ -105,7 +140,13 @@ def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
if not city or not date_str or date_str == today: if not city or not date_str or date_str == today:
continue continue
actual_high, filled = _actual_high_for(history, settlement_history, city, date_str) actual_high, filled, truth_meta = _actual_high_for(
history,
truth_history or {},
settlement_history,
city,
date_str,
)
if actual_high is None: if actual_high is None:
continue continue
if filled: if filled:
@@ -167,13 +208,20 @@ def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
"max_so_far_gap": max_so_far_gap, "max_so_far_gap": max_so_far_gap,
"peak_flag": peak_flag, "peak_flag": peak_flag,
"sample_source": "snapshot", "sample_source": "snapshot",
**truth_meta,
} }
) )
return samples, filled_actual_from_history return samples, filled_actual_from_history
def _extract_daily_record_samples(history, settlement_history=None, excluded_keys=None): def _extract_daily_record_samples(
history,
training_feature_history=None,
truth_history=None,
settlement_history=None,
excluded_keys=None,
):
samples = [] samples = []
filled_actual_from_history = 0 filled_actual_from_history = 0
today = datetime.utcnow().strftime("%Y-%m-%d") today = datetime.utcnow().strftime("%Y-%m-%d")
@@ -189,13 +237,18 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
if (city, date_str) in excluded_keys: if (city, date_str) in excluded_keys:
continue continue
actual_high = _sf(record.get("actual_high")) actual_high = _sf(record.get("actual_high"))
truth_meta = ((truth_history or {}).get(city) or {}).get(date_str) or {}
if actual_high is None:
actual_high = _sf(truth_meta.get("actual_high"))
if actual_high is None: if actual_high is None:
actual_high = _sf((city_settlement.get(date_str) or {}).get("max_temp")) actual_high = _sf((city_settlement.get(date_str) or {}).get("max_temp"))
if actual_high is not None: if actual_high is not None:
filled_actual_from_history += 1 filled_actual_from_history += 1
deb_prediction = _sf(record.get("deb_prediction")) feature_record = ((training_feature_history or {}).get(city) or {}).get(date_str) or {}
raw_mu = _sf(record.get("mu")) or deb_prediction source_record = feature_record if isinstance(feature_record, dict) and feature_record else record
forecasts = record.get("forecasts") or {} deb_prediction = _sf(source_record.get("deb_prediction"))
raw_mu = _sf(source_record.get("mu")) or deb_prediction
forecasts = source_record.get("forecasts") or {}
if not isinstance(forecasts, dict): if not isinstance(forecasts, dict):
forecasts = {} forecasts = {}
forecast_values = [val for val in (_sf(v) for v in forecasts.values()) if val is not None] forecast_values = [val for val in (_sf(v) for v in forecasts.values()) if val is not None]
@@ -203,7 +256,7 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
forecast_median = ( forecast_median = (
forecast_values[len(forecast_values) // 2] if forecast_values else None forecast_values[len(forecast_values) // 2] if forecast_values else None
) )
feature_snapshot = record.get("probability_features") or {} feature_snapshot = source_record.get("probability_features") or {}
if not isinstance(feature_snapshot, dict): if not isinstance(feature_snapshot, dict):
feature_snapshot = {} feature_snapshot = {}
@@ -243,15 +296,21 @@ def _extract_daily_record_samples(history, settlement_history=None, excluded_key
"max_so_far_gap": max_so_far_gap, "max_so_far_gap": max_so_far_gap,
"peak_flag": peak_flag, "peak_flag": peak_flag,
"sample_source": "daily_record", "sample_source": "daily_record",
"settlement_source": truth_meta.get("settlement_source"),
"settlement_station_code": truth_meta.get("settlement_station_code"),
"truth_version": truth_meta.get("truth_version"),
"truth_updated_by": truth_meta.get("updated_by"),
"truth_updated_at": truth_meta.get("truth_updated_at"),
} }
) )
return samples, filled_actual_from_history return samples, filled_actual_from_history
def _extract_samples(history, settlement_history=None, snapshot_rows=None): def _extract_samples(history, training_feature_history=None, truth_history=None, settlement_history=None, snapshot_rows=None):
snapshot_samples, snapshot_filled = _extract_snapshot_samples( snapshot_samples, snapshot_filled = _extract_snapshot_samples(
history, history,
snapshot_rows or [], truth_history=truth_history,
snapshot_rows=snapshot_rows or [],
settlement_history=settlement_history, settlement_history=settlement_history,
) )
excluded_keys = { excluded_keys = {
@@ -260,6 +319,8 @@ def _extract_samples(history, settlement_history=None, snapshot_rows=None):
} }
daily_samples, daily_filled = _extract_daily_record_samples( daily_samples, daily_filled = _extract_daily_record_samples(
history, history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history, settlement_history=settlement_history,
excluded_keys=excluded_keys, excluded_keys=excluded_keys,
) )
@@ -301,10 +362,14 @@ def main():
args = parser.parse_args() args = parser.parse_args()
history = _load_history_with_fallback(args.history_file) history = _load_history_with_fallback(args.history_file)
training_feature_history = _load_training_feature_history()
truth_history = _load_truth_history()
settlement_history = _load_json_if_exists(args.settlement_history) settlement_history = _load_json_if_exists(args.settlement_history)
snapshot_rows = _load_snapshot_rows(args.snapshot_file) snapshot_rows = _load_snapshot_rows(args.snapshot_file)
samples, filled_actual_from_history = _extract_samples( samples, filled_actual_from_history = _extract_samples(
history, history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history, settlement_history=settlement_history,
snapshot_rows=snapshot_rows, snapshot_rows=snapshot_rows,
) )
+114
View File
@@ -0,0 +1,114 @@
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
ProbabilitySnapshotRepository,
TrainingFeatureRecordRepository,
get_state_storage_mode,
)
def _load_legacy_snapshot_rows(path: str):
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 _spread_from_ensemble(ensemble: dict):
if not isinstance(ensemble, dict):
return None
try:
p10 = float(ensemble.get("p10"))
p90 = float(ensemble.get("p90"))
except Exception:
return None
if p90 < p10:
return None
return max(0.1, round((p90 - p10) / 2.56, 3))
def main():
parser = argparse.ArgumentParser(
description="Restore permanent training feature history from snapshot archives."
)
parser.add_argument(
"--snapshot-file",
default=os.path.join(PROJECT_ROOT, "data", "probability_training_snapshots.jsonl"),
)
args = parser.parse_args()
rows = []
if get_state_storage_mode() == "sqlite":
rows.extend(ProbabilitySnapshotRepository().load_all_rows())
rows.extend(_load_legacy_snapshot_rows(args.snapshot_file))
latest = {}
for row in rows:
city = str(row.get("city") or "").strip().lower()
date_str = str(row.get("date") or "").strip()
ts = str(row.get("timestamp") or "")
if not city or not date_str:
continue
key = (city, date_str)
current = latest.get(key)
if current is None or ts >= str(current.get("timestamp") or ""):
latest[key] = row
repo = TrainingFeatureRecordRepository()
restored = 0
for (city, date_str), row in latest.items():
repo.upsert_record(
city,
date_str,
{
"forecasts": row.get("multi_model") or {},
"deb_prediction": row.get("deb_prediction"),
"mu": row.get("raw_mu"),
"probability_features": {
"raw_mu": row.get("raw_mu"),
"raw_sigma": row.get("raw_sigma"),
"deb_prediction": row.get("deb_prediction"),
"ens_median": ((row.get("ensemble") or {}).get("median")),
"ensemble_spread": _spread_from_ensemble(row.get("ensemble") or {}),
"max_so_far": row.get("max_so_far"),
"peak_status": row.get("peak_status"),
},
"prob_snapshot": row.get("prob_snapshot") or [],
"shadow_prob_snapshot": row.get("shadow_prob_snapshot") or [],
"probability_calibration": {
"engine": row.get("probability_engine"),
"mode": row.get("probability_mode"),
"calibration_version": row.get("calibration_version"),
"calibration_source": row.get("calibration_source"),
"calibrated_mu": row.get("calibrated_mu"),
"calibrated_sigma": row.get("calibrated_sigma"),
},
"observation": row.get("observation") or {},
"snapshot_timestamp": row.get("timestamp"),
},
)
restored += 1
print(json.dumps({"restored_feature_records": restored}, ensure_ascii=False))
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
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.data_collection.city_registry import CITY_REGISTRY # noqa: E402
from src.database.runtime_state import TruthRecordRepository # noqa: E402
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_load_history_with_fallback,
_load_json_if_exists,
)
def _sf(value):
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _truth_meta(city: str) -> dict:
city_meta = CITY_REGISTRY.get(city) or {}
return {
"settlement_source": str(city_meta.get("settlement_source") or "metar").strip().lower(),
"settlement_station_code": str(
city_meta.get("settlement_station_code") or city_meta.get("icao") or ""
).strip().upper()
or None,
"settlement_station_label": str(
city_meta.get("settlement_station_label")
or city_meta.get("airport_name")
or city_meta.get("name")
or ""
).strip()
or None,
}
def main() -> None:
parser = argparse.ArgumentParser(
description="Restore permanent training truth history from settlement history and recent runtime cache."
)
parser.add_argument(
"--history-file",
default=_default_history_arg(),
)
parser.add_argument(
"--settlement-history",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"settlement_history.json",
),
)
parser.add_argument(
"--truth-version",
default="v1",
)
args = parser.parse_args()
repo = TruthRecordRepository()
settlement_history = _load_json_if_exists(args.settlement_history)
runtime_history = _load_history_with_fallback(args.history_file)
restored = 0
for city, city_rows in (settlement_history or {}).items():
if not isinstance(city_rows, dict):
continue
meta = _truth_meta(city)
for date_str, payload in city_rows.items():
if not isinstance(payload, dict):
continue
actual_high = _sf(payload.get("max_temp"))
if actual_high is None:
continue
repo.upsert_truth(
city=city,
target_date=str(date_str),
actual_high=actual_high,
settlement_source=meta["settlement_source"],
settlement_station_code=meta["settlement_station_code"],
settlement_station_label=meta["settlement_station_label"],
truth_version=args.truth_version,
updated_by="restore:settlement_history",
source_payload=payload,
is_final=True,
reason="restore_training_truth_history",
)
restored += 1
merged_recent = 0
for city, city_rows in (runtime_history or {}).items():
if not isinstance(city_rows, dict):
continue
meta = _truth_meta(city)
for date_str, payload in city_rows.items():
if not isinstance(payload, dict):
continue
actual_high = _sf(payload.get("actual_high"))
if actual_high is None:
continue
repo.upsert_truth(
city=city,
target_date=str(date_str),
actual_high=actual_high,
settlement_source=meta["settlement_source"],
settlement_station_code=meta["settlement_station_code"],
settlement_station_label=meta["settlement_station_label"],
truth_version=args.truth_version,
updated_by="restore:runtime_daily_records",
source_payload={
"actual_high": actual_high,
"payload_json": payload,
},
is_final=True,
reason="restore_training_truth_history",
)
merged_recent += 1
print(
json.dumps(
{
"restored_from_settlement_history": restored,
"merged_recent_runtime_records": merged_recent,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
+224 -2
View File
@@ -1,13 +1,17 @@
import os import os
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
import requests import requests
from src.analysis.settlement_rounding import apply_city_settlement from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.wunderground_sources import fetch_wunderground_historical_high
from loguru import logger from loguru import logger
from src.database.runtime_state import ( from src.database.runtime_state import (
DailyRecordRepository, DailyRecordRepository,
STATE_STORAGE_DUAL, STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE, STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode, get_state_storage_mode,
) )
@@ -44,6 +48,9 @@ else:
_history_cache = {} _history_cache = {}
_history_mtime = 0 _history_mtime = 0
_daily_record_repo = DailyRecordRepository() _daily_record_repo = DailyRecordRepository()
_training_feature_repo = TrainingFeatureRecordRepository()
_truth_record_repo = TruthRecordRepository()
_TRUTH_VERSION = "v1"
def _sf(value): def _sf(value):
@@ -168,6 +175,80 @@ def _resolve_city_history_context(city_name: str):
return city_key, city_meta return city_key, city_meta
def _truth_meta_for_city(city_meta: dict) -> dict:
if not isinstance(city_meta, dict):
city_meta = {}
return {
"settlement_source": str(city_meta.get("settlement_source") or "metar").strip().lower(),
"settlement_station_code": str(city_meta.get("settlement_station_code") or city_meta.get("icao") or "").strip().upper() or None,
"settlement_station_label": str(
city_meta.get("settlement_station_label")
or city_meta.get("airport_name")
or city_meta.get("name")
or ""
).strip()
or None,
}
def _persist_truth_record(
city_name: str,
date_str: str,
actual_high: float,
*,
city_meta: Optional[dict] = None,
updated_by: str,
reason: str,
source_payload: Optional[dict] = None,
is_final: bool = True,
) -> None:
city_key, resolved_meta = _resolve_city_history_context(city_name)
meta = city_meta if isinstance(city_meta, dict) else resolved_meta
if not city_key or not isinstance(meta, dict):
return
truth_meta = _truth_meta_for_city(meta)
_truth_record_repo.upsert_truth(
city=city_key,
target_date=date_str,
actual_high=float(actual_high),
settlement_source=truth_meta["settlement_source"],
settlement_station_code=truth_meta["settlement_station_code"],
settlement_station_label=truth_meta["settlement_station_label"],
truth_version=_TRUTH_VERSION,
updated_by=updated_by,
source_payload=source_payload,
is_final=is_final,
reason=reason,
)
def _persist_training_feature_record(
city_name: str,
date_str: str,
*,
forecasts: Optional[dict],
deb_prediction: Optional[float],
mu: Optional[float],
probability_features: Optional[dict],
probabilities: Optional[list],
shadow_probabilities: Optional[list],
probability_calibration: Optional[dict],
) -> None:
city_key, _ = _resolve_city_history_context(city_name)
if not city_key:
return
payload = {
"forecasts": forecasts or {},
"deb_prediction": deb_prediction,
"mu": mu,
"probability_features": probability_features or {},
"prob_snapshot": probabilities or [],
"shadow_prob_snapshot": shadow_probabilities or [],
"probability_calibration": probability_calibration or {},
}
_training_feature_repo.upsert_record(city_key, date_str, payload)
def _parse_hko_ryes_max_temp(payload): def _parse_hko_ryes_max_temp(payload):
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
@@ -275,6 +356,15 @@ def _reconcile_recent_metar_actual_highs(city_name: str, lookback_days: int = 7)
if t_c is None: if t_c is None:
continue continue
corrected = round(t_c * 9 / 5 + 32, 1) if use_fahrenheit else round(t_c, 1) corrected = round(t_c * 9 / 5 + 32, 1) if use_fahrenheit else round(t_c, 1)
_persist_truth_record(
city_key,
d,
corrected,
city_meta=city_meta,
updated_by="backfill:metar_history",
reason="reconcile_recent_actual_highs",
source_payload={"icao": icao, "actual_high": corrected, "source": "metar"},
)
rec = city_data.get(d) or {} rec = city_data.get(d) or {}
old = rec.get("actual_high") old = rec.get("actual_high")
try: try:
@@ -367,6 +457,19 @@ def _reconcile_recent_hko_actual_highs(city_name: str, lookback_days: int = 14):
if use_fahrenheit if use_fahrenheit
else round(max_temp_c, 1) else round(max_temp_c, 1)
) )
_persist_truth_record(
city_key,
date_str,
corrected,
city_meta=city_meta,
updated_by="backfill:hko_history",
reason="reconcile_recent_actual_highs",
source_payload={
"station_code": station_code,
"actual_high": corrected,
"source": "hko",
},
)
rec = city_data.get(date_str) or {} rec = city_data.get(date_str) or {}
old = rec.get("actual_high") old = rec.get("actual_high")
try: try:
@@ -486,6 +589,19 @@ def _reconcile_recent_noaa_actual_highs(city_name: str, lookback_days: int = 14)
if use_fahrenheit if use_fahrenheit
else int(corrected) else int(corrected)
) )
_persist_truth_record(
city_key,
date_key,
next_value,
city_meta=city_meta,
updated_by="backfill:noaa_history",
reason="reconcile_recent_actual_highs",
source_payload={
"station_code": station_code,
"actual_high": next_value,
"source": "noaa",
},
)
rec = city_data.get(date_key) or {} rec = city_data.get(date_key) or {}
old = rec.get("actual_high") old = rec.get("actual_high")
try: try:
@@ -513,6 +629,79 @@ def _reconcile_recent_noaa_actual_highs(city_name: str, lookback_days: int = 14)
return {"ok": False, "reason": str(e), "updated": 0} return {"ok": False, "reason": str(e), "updated": 0}
def _reconcile_recent_wunderground_actual_highs(city_name: str, lookback_days: int = 14):
try:
city_key, city_meta = _resolve_city_history_context(city_name)
if not city_key or not isinstance(city_meta, dict):
return {"ok": False, "reason": "unknown_city", "updated": 0}
settlement_url = str(city_meta.get("settlement_url") or "").strip()
if not settlement_url:
return {"ok": False, "reason": "missing_settlement_url", "updated": 0}
tz_offset = int(city_meta.get("tz_offset") or 0)
history_file = _get_history_file_path()
data = load_history(history_file)
city_data = data.get(city_key) or {}
if not isinstance(city_data, dict) or not city_data:
return {"ok": True, "reason": "no_city_history", "updated": 0}
local_now = datetime.utcnow() + timedelta(seconds=tz_offset)
local_today = local_now.strftime("%Y-%m-%d")
cutoff = (local_now - timedelta(days=max(lookback_days, 1) + 1)).strftime(
"%Y-%m-%d"
)
target_dates = sorted(
d for d in city_data.keys() if isinstance(d, str) and cutoff <= d < local_today
)
if not target_dates:
return {"ok": True, "reason": "no_target_dates", "updated": 0}
updated = 0
scanned_dates = 0
for date_str in target_dates:
result = fetch_wunderground_historical_high(city_key, date_str, url=settlement_url)
if not result.get("ok"):
continue
scanned_dates += 1
corrected = _sf(result.get("actual_high"))
if corrected is None:
continue
rec = city_data.get(date_str) or {}
old = rec.get("actual_high")
try:
old_val = float(old) if old is not None else None
except Exception:
old_val = None
if old_val is None or abs(old_val - corrected) >= 0.1:
rec["actual_high"] = corrected
city_data[date_str] = rec
updated += 1
_persist_truth_record(
city_key,
date_str,
corrected,
city_meta=city_meta,
updated_by="backfill:wunderground_history",
reason="reconcile_recent_actual_highs",
source_payload=result,
)
if updated > 0:
data[city_key] = city_data
save_history(history_file, data)
return {
"ok": True,
"updated": updated,
"scanned_dates": scanned_dates,
"station_code": city_meta.get("settlement_station_code"),
"source": "wunderground",
}
except Exception as e:
return {"ok": False, "reason": str(e), "updated": 0}
def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7): def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7):
""" """
Reconcile recent `actual_high` values using the city's official settlement source. Reconcile recent `actual_high` values using the city's official settlement source.
@@ -526,6 +715,8 @@ def reconcile_recent_actual_highs(city_name: str, lookback_days: int = 7):
return _reconcile_recent_hko_actual_highs(city_key, lookback_days=lookback_days) return _reconcile_recent_hko_actual_highs(city_key, lookback_days=lookback_days)
if settlement_source == "noaa": if settlement_source == "noaa":
return _reconcile_recent_noaa_actual_highs(city_key, lookback_days=lookback_days) return _reconcile_recent_noaa_actual_highs(city_key, lookback_days=lookback_days)
if settlement_source == "wunderground":
return _reconcile_recent_wunderground_actual_highs(city_key, lookback_days=lookback_days)
return _reconcile_recent_metar_actual_highs(city_key, lookback_days=lookback_days) return _reconcile_recent_metar_actual_highs(city_key, lookback_days=lookback_days)
@@ -541,14 +732,14 @@ def bootstrap_recent_daily_history_if_missing(city_name: str, lookback_days: int
return {"ok": False, "reason": "unknown_city", "seeded": 0, "updated": 0} return {"ok": False, "reason": "unknown_city", "seeded": 0, "updated": 0}
settlement_source = str(city_meta.get("settlement_source") or "metar").strip().lower() settlement_source = str(city_meta.get("settlement_source") or "metar").strip().lower()
if settlement_source not in {"metar", "hko", "noaa"}: if settlement_source not in {"metar", "hko", "noaa", "wunderground"}:
return {"ok": True, "reason": "unsupported_settlement_source", "seeded": 0, "updated": 0} return {"ok": True, "reason": "unsupported_settlement_source", "seeded": 0, "updated": 0}
icao = str(city_meta.get("icao") or "").strip().upper() icao = str(city_meta.get("icao") or "").strip().upper()
station_code = str(city_meta.get("settlement_station_code") or "").strip().upper() station_code = str(city_meta.get("settlement_station_code") or "").strip().upper()
if settlement_source == "metar" and not icao: if settlement_source == "metar" and not icao:
return {"ok": False, "reason": "missing_icao", "seeded": 0, "updated": 0} return {"ok": False, "reason": "missing_icao", "seeded": 0, "updated": 0}
if settlement_source in {"hko", "noaa"} and not station_code: if settlement_source in {"hko", "noaa", "wunderground"} and not station_code:
return {"ok": False, "reason": "missing_station_code", "seeded": 0, "updated": 0} return {"ok": False, "reason": "missing_station_code", "seeded": 0, "updated": 0}
tz_offset = int(city_meta.get("tz_offset") or 0) tz_offset = int(city_meta.get("tz_offset") or 0)
@@ -726,6 +917,37 @@ def update_daily_record(
if compact_calibration is not None: if compact_calibration is not None:
existing["probability_calibration"] = compact_calibration existing["probability_calibration"] = compact_calibration
if actual_high is not None:
try:
_persist_truth_record(
city_name,
date_str,
float(actual_high),
updated_by="runtime:update_daily_record",
reason="update_daily_record",
source_payload={
"actual_high": actual_high,
"deb_prediction": deb_prediction,
"mu": next_mu,
},
)
except Exception as e:
logger.error(f"Error persisting truth record city={city_name} date={date_str}: {e}")
try:
_persist_training_feature_record(
city_name,
date_str,
forecasts=merged_forecasts,
deb_prediction=existing.get("deb_prediction"),
mu=existing.get("mu"),
probability_features=existing.get("probability_features"),
probabilities=existing.get("prob_snapshot"),
shadow_probabilities=existing.get("shadow_prob_snapshot"),
probability_calibration=existing.get("probability_calibration"),
)
except Exception as e:
logger.error(f"Error persisting training feature record city={city_name} date={date_str}: {e}")
# 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量) # 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d") cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
for city in list(data.keys()): for city in list(data.keys()):
@@ -9,6 +9,7 @@ from src.database.runtime_state import (
ProbabilitySnapshotRepository, ProbabilitySnapshotRepository,
STATE_STORAGE_DUAL, STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE, STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
get_state_storage_mode, get_state_storage_mode,
) )
@@ -17,6 +18,7 @@ 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() _snapshot_repo = ProbabilitySnapshotRepository()
_training_feature_repo = TrainingFeatureRecordRepository()
def _sf(value: Any) -> Optional[float]: def _sf(value: Any) -> Optional[float]:
@@ -260,6 +262,36 @@ def append_probability_snapshot(
mode = get_state_storage_mode() mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}: if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
_snapshot_repo.append_snapshot(payload) _snapshot_repo.append_snapshot(payload)
_training_feature_repo.upsert_record(
city_key,
local_date,
{
"forecasts": payload.get("multi_model") or {},
"deb_prediction": payload.get("deb_prediction"),
"mu": payload.get("raw_mu"),
"probability_features": {
"raw_mu": payload.get("raw_mu"),
"raw_sigma": payload.get("raw_sigma"),
"deb_prediction": payload.get("deb_prediction"),
"ens_median": (payload.get("ensemble") or {}).get("median"),
"ensemble_spread": None,
"max_so_far": payload.get("max_so_far"),
"peak_status": payload.get("peak_status"),
},
"prob_snapshot": payload.get("prob_snapshot") or [],
"shadow_prob_snapshot": payload.get("shadow_prob_snapshot") or [],
"probability_calibration": {
"engine": payload.get("probability_engine"),
"mode": payload.get("probability_mode"),
"calibration_version": payload.get("calibration_version"),
"calibration_source": payload.get("calibration_source"),
"calibrated_mu": payload.get("calibrated_mu"),
"calibrated_sigma": payload.get("calibrated_sigma"),
},
"observation": payload.get("observation") or {},
"snapshot_timestamp": payload.get("timestamp"),
},
)
if mode != STATE_STORAGE_SQLITE: if mode != STATE_STORAGE_SQLITE:
with open(path, "a", encoding="utf-8") as fh: with open(path, "a", encoding="utf-8") as fh:
@@ -5,8 +5,10 @@ import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import requests
from loguru import logger from loguru import logger
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
from src.analysis.settlement_rounding import apply_city_settlement
class WundergroundSourceMixin: class WundergroundSourceMixin:
@@ -502,3 +504,92 @@ class WundergroundSourceMixin:
} }
self._set_settlement_cache(cache_key, payload) self._set_settlement_cache(cache_key, payload)
return payload return payload
def _normalize_wu_history_date_url(url: str, target_date: str) -> str:
normalized = str(url or "").strip().rstrip("/")
normalized = re.sub(r"/date/\d{4}-\d{2}-\d{2}$", "", normalized, flags=re.IGNORECASE)
return f"{normalized}/date/{target_date}"
def fetch_wunderground_historical_high(
city: str,
target_date: str,
*,
url: Optional[str] = None,
timeout: int = 15,
session: Optional[requests.Session] = None,
) -> Dict[str, Any]:
city_key = str(city or "").strip().lower()
city_meta = CITY_REGISTRY.get(city_key) or {}
history_url = _normalize_wu_history_date_url(
url or str(city_meta.get("settlement_url") or "").strip(),
target_date,
)
if not history_url:
return {"ok": False, "reason": "missing_history_url", "city": city_key, "date": target_date}
requester = session or requests.Session()
try:
response = requester.get(
history_url,
headers={
"User-Agent": "Mozilla/5.0",
"Referer": history_url,
},
timeout=timeout,
)
response.raise_for_status()
html = str(response.text or "")
except Exception as exc:
logger.warning(f"Wunderground history fetch failed city={city_key} date={target_date}: {exc}")
return {
"ok": False,
"reason": "fetch_failed",
"city": city_key,
"date": target_date,
"history_url": history_url,
"error": str(exc),
}
app_state = WundergroundSourceMixin._wu_extract_app_state(html)
if not isinstance(app_state, dict):
return {
"ok": False,
"reason": "missing_app_state",
"city": city_key,
"date": target_date,
"history_url": history_url,
}
utc_offset_seconds = int(city_meta.get("tz_offset") or 0)
obs = WundergroundSourceMixin._wu_extract_history_observations(
app_state,
utc_offset_seconds=utc_offset_seconds,
)
if not obs:
return {
"ok": False,
"reason": "missing_observations",
"city": city_key,
"date": target_date,
"history_url": history_url,
}
raw_max_temp_c = max(float(point.get("temp")) for point in obs if point.get("temp") is not None)
settled_actual_high = apply_city_settlement(city_key, raw_max_temp_c)
station_code = str(city_meta.get("settlement_station_code") or "").strip().upper() or None
station_label = str(city_meta.get("settlement_station_label") or "").strip() or None
return {
"ok": True,
"city": city_key,
"date": target_date,
"history_url": history_url,
"raw_max_temp_c": round(raw_max_temp_c, 1),
"actual_high": float(settled_actual_high),
"settlement_source": "wunderground",
"settlement_station_code": station_code,
"settlement_station_label": station_label,
"observation_count": len(obs),
"observations": obs,
}
+361
View File
@@ -74,6 +74,48 @@ class RuntimeStateDB:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS truth_records_store (
city TEXT NOT NULL,
target_date TEXT NOT NULL,
actual_high REAL NOT NULL,
settlement_source TEXT,
settlement_station_code TEXT,
settlement_station_label TEXT,
truth_version TEXT,
updated_by TEXT,
updated_at REAL NOT NULL,
source_payload_json TEXT,
is_final INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (city, target_date)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS truth_revisions_store (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL,
target_date TEXT NOT NULL,
previous_actual_high REAL,
next_actual_high REAL NOT NULL,
previous_source TEXT,
next_source TEXT,
truth_version TEXT,
updated_by TEXT,
updated_at REAL NOT NULL,
reason TEXT,
payload_json TEXT
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_truth_records_city_date ON truth_records_store(city, target_date)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_truth_revisions_city_date ON truth_revisions_store(city, target_date, id DESC)"
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS telegram_alert_last_by_city ( CREATE TABLE IF NOT EXISTS telegram_alert_last_by_city (
@@ -117,6 +159,20 @@ class RuntimeStateDB:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_probability_snapshot_city_date ON probability_training_snapshots_store(city, target_date, id DESC)" "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 training_feature_records_store (
city TEXT NOT NULL,
target_date TEXT NOT NULL,
updated_at REAL NOT NULL,
payload_json TEXT NOT NULL,
PRIMARY KEY (city, target_date)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_training_feature_records_city_date ON training_feature_records_store(city, target_date)"
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS open_meteo_cache_store ( CREATE TABLE IF NOT EXISTS open_meteo_cache_store (
@@ -238,6 +294,251 @@ class DailyRecordRepository:
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
class TruthRecordRepository:
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, actual_high, settlement_source, settlement_station_code,
settlement_station_label, truth_version, updated_by, updated_at,
source_payload_json, is_final
FROM truth_records_store
ORDER BY city, target_date
"""
).fetchall()
for row in rows:
payload: Dict[str, Any] = {
"actual_high": float(row["actual_high"]),
"settlement_source": row["settlement_source"],
"settlement_station_code": row["settlement_station_code"],
"settlement_station_label": row["settlement_station_label"],
"truth_version": row["truth_version"],
"updated_by": row["updated_by"],
"truth_updated_at": float(row["updated_at"]),
"is_final": bool(row["is_final"]),
}
if row["source_payload_json"]:
try:
payload["source_payload"] = json.loads(row["source_payload_json"])
except Exception:
pass
out.setdefault(str(row["city"]), {})[str(row["target_date"])] = payload
return out
def get_record(self, city: str, target_date: str) -> Optional[Dict[str, Any]]:
with self.db.connect() as conn:
row = conn.execute(
"""
SELECT actual_high, settlement_source, settlement_station_code,
settlement_station_label, truth_version, updated_by, updated_at,
source_payload_json, is_final
FROM truth_records_store
WHERE city = ? AND target_date = ?
""",
(city, target_date),
).fetchone()
if not row:
return None
payload: Dict[str, Any] = {
"actual_high": float(row["actual_high"]),
"settlement_source": row["settlement_source"],
"settlement_station_code": row["settlement_station_code"],
"settlement_station_label": row["settlement_station_label"],
"truth_version": row["truth_version"],
"updated_by": row["updated_by"],
"truth_updated_at": float(row["updated_at"]),
"is_final": bool(row["is_final"]),
}
if row["source_payload_json"]:
try:
payload["source_payload"] = json.loads(row["source_payload_json"])
except Exception:
pass
return payload
def upsert_truth(
self,
*,
city: str,
target_date: str,
actual_high: float,
settlement_source: Optional[str],
settlement_station_code: Optional[str],
settlement_station_label: Optional[str],
truth_version: str,
updated_by: str,
source_payload: Optional[Dict[str, Any]] = None,
is_final: bool = True,
reason: Optional[str] = None,
) -> bool:
updated_at = time.time()
payload_json = (
json.dumps(source_payload, ensure_ascii=False) if source_payload is not None else None
)
with self.db.connect() as conn:
current = conn.execute(
"""
SELECT actual_high, settlement_source, source_payload_json
FROM truth_records_store
WHERE city = ? AND target_date = ?
""",
(city, target_date),
).fetchone()
changed = True
if current:
prev_actual = float(current["actual_high"])
prev_source = str(current["settlement_source"] or "")
next_source = str(settlement_source or "")
changed = (
abs(prev_actual - float(actual_high)) >= 0.0001
or prev_source != next_source
or str(current["source_payload_json"] or "") != str(payload_json or "")
)
if changed:
conn.execute(
"""
INSERT INTO truth_revisions_store (
city, target_date, previous_actual_high, next_actual_high,
previous_source, next_source, truth_version, updated_by,
updated_at, reason, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
city,
target_date,
prev_actual,
float(actual_high),
prev_source or None,
next_source or None,
truth_version,
updated_by,
updated_at,
reason,
payload_json,
),
)
conn.execute(
"""
INSERT INTO truth_records_store (
city, target_date, actual_high, settlement_source,
settlement_station_code, settlement_station_label, truth_version,
updated_by, updated_at, source_payload_json, is_final
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(city, target_date) DO UPDATE SET
actual_high = excluded.actual_high,
settlement_source = excluded.settlement_source,
settlement_station_code = excluded.settlement_station_code,
settlement_station_label = excluded.settlement_station_label,
truth_version = excluded.truth_version,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at,
source_payload_json = excluded.source_payload_json,
is_final = excluded.is_final
""",
(
city,
target_date,
float(actual_high),
settlement_source,
settlement_station_code,
settlement_station_label,
truth_version,
updated_by,
updated_at,
payload_json,
1 if is_final else 0,
),
)
conn.commit()
return changed
def replace_all(self, rows: Dict[str, Dict[str, Dict[str, Any]]]) -> int:
count = 0
with self.db.connect() as conn:
conn.execute("DELETE FROM truth_records_store")
conn.execute("DELETE FROM truth_revisions_store")
for city, city_rows in (rows or {}).items():
if not isinstance(city_rows, dict):
continue
for target_date, record in city_rows.items():
if not isinstance(record, dict):
continue
actual_high = record.get("actual_high")
if actual_high is None:
continue
payload_json = (
json.dumps(record.get("source_payload"), ensure_ascii=False)
if record.get("source_payload") is not None
else None
)
conn.execute(
"""
INSERT INTO truth_records_store (
city, target_date, actual_high, settlement_source,
settlement_station_code, settlement_station_label, truth_version,
updated_by, updated_at, source_payload_json, is_final
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
city,
target_date,
float(actual_high),
record.get("settlement_source"),
record.get("settlement_station_code"),
record.get("settlement_station_label"),
record.get("truth_version") or "v1",
record.get("updated_by") or "replace_all",
float(record.get("truth_updated_at") or time.time()),
payload_json,
1 if record.get("is_final", True) else 0,
),
)
count += 1
conn.commit()
return count
class TruthRevisionRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def load_revisions(self, city: str, target_date: str) -> List[Dict[str, Any]]:
with self.db.connect() as conn:
rows = conn.execute(
"""
SELECT previous_actual_high, next_actual_high, previous_source, next_source,
truth_version, updated_by, updated_at, reason, payload_json
FROM truth_revisions_store
WHERE city = ? AND target_date = ?
ORDER BY id ASC
""",
(city, target_date),
).fetchall()
out: List[Dict[str, Any]] = []
for row in rows:
entry: Dict[str, Any] = {
"previous_actual_high": row["previous_actual_high"],
"next_actual_high": row["next_actual_high"],
"previous_source": row["previous_source"],
"next_source": row["next_source"],
"truth_version": row["truth_version"],
"updated_by": row["updated_by"],
"updated_at": float(row["updated_at"]),
"reason": row["reason"],
}
if row["payload_json"]:
try:
entry["payload"] = json.loads(row["payload_json"])
except Exception:
pass
out.append(entry)
return out
class TelegramAlertStateRepository: class TelegramAlertStateRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None): def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance() self.db = db or RuntimeStateDB.instance()
@@ -429,6 +730,66 @@ class ProbabilitySnapshotRepository:
return count return count
class TrainingFeatureRecordRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
def upsert_record(self, city: str, target_date: str, payload: Dict[str, Any]) -> None:
with self.db.connect() as conn:
conn.execute(
"""
INSERT INTO training_feature_records_store (
city, target_date, updated_at, payload_json
) VALUES (?, ?, ?, ?)
ON CONFLICT(city, target_date) DO UPDATE SET
updated_at = excluded.updated_at,
payload_json = excluded.payload_json
""",
(
city,
target_date,
time.time(),
json.dumps(payload, ensure_ascii=False),
),
)
conn.commit()
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 training_feature_records_store
ORDER BY city, target_date
"""
).fetchall()
for row in rows:
try:
payload = json.loads(row["payload_json"])
except Exception:
continue
out.setdefault(str(row["city"]), {})[str(row["target_date"])] = payload
return out
def get_record(self, city: str, target_date: str) -> Optional[Dict[str, Any]]:
with self.db.connect() as conn:
row = conn.execute(
"""
SELECT payload_json
FROM training_feature_records_store
WHERE city = ? AND target_date = ?
""",
(city, target_date),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload_json"])
except Exception:
return None
class OpenMeteoCacheRepository: class OpenMeteoCacheRepository:
def __init__(self, db: Optional[RuntimeStateDB] = None): def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance() self.db = db or RuntimeStateDB.instance()
+84 -6
View File
@@ -13,6 +13,8 @@ from src.database.runtime_state import (
ProbabilitySnapshotRepository, ProbabilitySnapshotRepository,
STATE_STORAGE_FILE, STATE_STORAGE_FILE,
STATE_STORAGE_SQLITE, STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode, get_state_storage_mode,
) )
@@ -288,23 +290,93 @@ def build_training_samples(
snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None, snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
if isinstance(history_data, dict): if isinstance(history_data, dict):
data = history_data runtime_history = history_data
elif get_state_storage_mode() == STATE_STORAGE_SQLITE: elif get_state_storage_mode() == STATE_STORAGE_SQLITE:
data = DailyRecordRepository().load_all() runtime_history = DailyRecordRepository().load_all()
else: else:
data = load_history(_history_file_path()) runtime_history = load_history(_history_file_path())
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
truth_history = TruthRecordRepository().load_all()
training_feature_history = TrainingFeatureRecordRepository().load_all()
else:
truth_history = runtime_history
training_feature_history = {}
snapshots = snapshot_index if isinstance(snapshot_index, dict) else load_snapshot_index() snapshots = snapshot_index if isinstance(snapshot_index, dict) else load_snapshot_index()
samples: List[Dict[str, Any]] = [] samples: List[Dict[str, Any]] = []
excluded_keys: set[tuple[str, str]] = set()
for city_name, city_records in (data or {}).items(): for (city_name, date_str), snapshot in (snapshots or {}).items():
if not isinstance(snapshot, dict):
continue
truth_row = ((truth_history.get(city_name) or {}).get(str(date_str)) or {})
target = _sf(truth_row.get("actual_high"))
if target is None:
runtime_record = ((runtime_history.get(city_name) or {}).get(str(date_str)) or {})
target = _sf(runtime_record.get("actual_high"))
if target is None:
continue
observation = snapshot.get("observation") if isinstance(snapshot.get("observation"), dict) else {}
current_forecasts = snapshot.get("multi_model") if isinstance(snapshot.get("multi_model"), dict) else {}
local_hour = _sf(observation.get("local_hour"))
if local_hour is None:
timestamp = _parse_timestamp(snapshot.get("timestamp"))
local_hour = float(timestamp.hour) if timestamp is not None else 12.0
feature_map, meta = build_runtime_feature_map(
city_name=city_name,
current_forecasts=current_forecasts,
deb_prediction=_sf(snapshot.get("deb_prediction")) or _sf(snapshot.get("raw_mu")),
current_temp=_sf(observation.get("current_temp")),
max_so_far=_sf(snapshot.get("max_so_far")),
humidity=_sf(observation.get("humidity")),
wind_speed_kt=_sf(observation.get("wind_speed_kt")),
visibility_mi=_sf(observation.get("visibility_mi")),
local_hour=int(local_hour),
local_date=str(date_str),
peak_status=str(snapshot.get("peak_status") or "before"),
history_data=truth_history,
)
if not feature_map:
continue
samples.append(
{
"city": _normalized_city_key(city_name),
"date": str(date_str),
"target": float(target),
"features": feature_map,
"vector": _features_to_vector(feature_map),
"history_count": int(meta.get("history_count") or 0),
"deb_prediction": _sf(snapshot.get("deb_prediction")) or _sf(snapshot.get("raw_mu")),
"forecasts": {
key: _sf(value)
for key, value in current_forecasts.items()
if _sf(value) is not None
},
"sample_source": "snapshot",
"settlement_source": truth_row.get("settlement_source"),
"settlement_station_code": truth_row.get("settlement_station_code"),
"truth_version": truth_row.get("truth_version"),
"truth_updated_by": truth_row.get("updated_by"),
"truth_updated_at": truth_row.get("truth_updated_at"),
}
)
excluded_keys.add((_normalized_city_key(city_name), str(date_str)))
training_source = training_feature_history or runtime_history or {}
for city_name, city_records in training_source.items():
if not isinstance(city_records, dict): if not isinstance(city_records, dict):
continue continue
ordered_dates = sorted(city_records.keys()) ordered_dates = sorted(city_records.keys())
for date_str in ordered_dates: for date_str in ordered_dates:
normalized_city = _normalized_city_key(city_name)
if (normalized_city, str(date_str)) in excluded_keys:
continue
record = city_records.get(date_str) record = city_records.get(date_str)
if not isinstance(record, dict): if not isinstance(record, dict):
continue continue
target = _sf(record.get("actual_high")) truth_row = ((truth_history.get(normalized_city) or {}).get(str(date_str)) or {})
target = _sf(truth_row.get("actual_high"))
if target is None:
target = _sf(((runtime_history.get(normalized_city) or {}).get(str(date_str)) or {}).get("actual_high"))
forecasts = record.get("forecasts") if isinstance(record.get("forecasts"), dict) else {} forecasts = record.get("forecasts") if isinstance(record.get("forecasts"), dict) else {}
if target is None or not forecasts: if target is None or not forecasts:
continue continue
@@ -321,7 +393,7 @@ def build_training_samples(
local_hour=12, local_hour=12,
local_date=str(date_str), local_date=str(date_str),
peak_status="before", peak_status="before",
history_data=data, history_data=truth_history,
) )
if not feature_map: if not feature_map:
continue continue
@@ -361,6 +433,12 @@ def build_training_samples(
for key, value in forecasts.items() for key, value in forecasts.items()
if _sf(value) is not None if _sf(value) is not None
}, },
"sample_source": "daily_record",
"settlement_source": truth_row.get("settlement_source"),
"settlement_station_code": truth_row.get("settlement_station_code"),
"truth_version": truth_row.get("truth_version"),
"truth_updated_by": truth_row.get("updated_by"),
"truth_updated_at": truth_row.get("truth_updated_at"),
} }
) )
samples.sort(key=lambda row: (row["date"], row["city"])) samples.sort(key=lambda row: (row["date"], row["city"]))
+33 -1
View File
@@ -1,4 +1,4 @@
from src.models.lgbm_features import build_runtime_feature_map from src.models.lgbm_features import build_runtime_feature_map, build_training_samples
def test_build_runtime_feature_map_derives_history_and_model_summary(): def test_build_runtime_feature_map_derives_history_and_model_summary():
@@ -59,3 +59,35 @@ def test_build_runtime_feature_map_returns_none_without_history():
assert feature_map is None assert feature_map is None
assert meta["reason"] == "no_history" assert meta["reason"] == "no_history"
def test_build_training_samples_prefers_truth_history_for_target():
history_data = {
"ankara": {
"2026-03-20": {"actual_high": 10.0},
"2026-03-21": {"actual_high": 11.0},
"2026-03-22": {"actual_high": 13.0},
"2026-03-23": {
"actual_high": 12.0,
"deb_prediction": 12.3,
"forecasts": {"Open-Meteo": 12.4, "ECMWF": 12.1},
},
}
}
snapshot_index = {
("ankara", "2026-03-23"): {
"city": "ankara",
"date": "2026-03-23",
"timestamp": "2026-03-23T10:00:00+03:00",
"raw_mu": 12.2,
"deb_prediction": 12.3,
"max_so_far": 11.8,
"peak_status": "before",
"multi_model": {"Open-Meteo": 12.4, "ECMWF": 12.1},
"observation": {"current_temp": 11.5, "humidity": 60.0, "wind_speed_kt": 8.0, "local_hour": 10},
}
}
samples = build_training_samples(history_data=history_data, snapshot_index=snapshot_index)
assert len(samples) == 1
assert samples[0]["sample_source"] == "snapshot"
+67 -7
View File
@@ -2,7 +2,8 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from src.analysis.probability_snapshot_archive import append_probability_snapshot import src.analysis.probability_snapshot_archive as snapshot_archive
from src.database.runtime_state import RuntimeStateDB, TrainingFeatureRecordRepository
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -10,10 +11,22 @@ def _force_file_mode(monkeypatch):
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "file") 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, monkeypatch):
archive_path = tmp_path / "probability_training_snapshots.jsonl" archive_path = tmp_path / "probability_training_snapshots.jsonl"
append_probability_snapshot( db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
monkeypatch.setattr(
snapshot_archive,
"_training_feature_repo",
TrainingFeatureRecordRepository(db),
)
monkeypatch.setattr(
snapshot_archive,
"_snapshot_repo",
snapshot_archive.ProbabilitySnapshotRepository(db),
)
snapshot_archive.append_probability_snapshot(
city_name="ankara", city_name="ankara",
local_date="2026-03-20", local_date="2026-03-20",
observation_time="2026-03-20T12:00:00+03:00", observation_time="2026-03-20T12:00:00+03:00",
@@ -77,8 +90,8 @@ def test_append_probability_snapshot_skips_near_duplicate(tmp_path: Path):
archive_path=str(archive_path), archive_path=str(archive_path),
) )
append_probability_snapshot(**kwargs) snapshot_archive.append_probability_snapshot(**kwargs)
append_probability_snapshot(**kwargs) snapshot_archive.append_probability_snapshot(**kwargs)
lines = archive_path.read_text(encoding="utf-8").strip().splitlines() lines = archive_path.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 1 assert len(lines) == 1
@@ -110,14 +123,61 @@ def test_append_probability_snapshot_writes_on_bucket_change(tmp_path: Path):
archive_path=str(archive_path), archive_path=str(archive_path),
) )
append_probability_snapshot( snapshot_archive.append_probability_snapshot(
probabilities=[{"value": 15, "probability": 0.552}], probabilities=[{"value": 15, "probability": 0.552}],
**base_kwargs, **base_kwargs,
) )
append_probability_snapshot( snapshot_archive.append_probability_snapshot(
probabilities=[{"value": 16, "probability": 0.552}], probabilities=[{"value": 16, "probability": 0.552}],
**base_kwargs, **base_kwargs,
) )
lines = archive_path.read_text(encoding="utf-8").strip().splitlines() lines = archive_path.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 2 assert len(lines) == 2
def test_append_probability_snapshot_dual_writes_training_feature_store(tmp_path: Path, monkeypatch):
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db"))
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
monkeypatch.setattr(
snapshot_archive,
"_training_feature_repo",
TrainingFeatureRecordRepository(db),
)
monkeypatch.setattr(
snapshot_archive,
"_snapshot_repo",
snapshot_archive.ProbabilitySnapshotRepository(db),
)
snapshot_archive.append_probability_snapshot(
city_name="ankara",
local_date="2026-03-20",
observation_time="2026-03-20T12:00:00+03:00",
temp_symbol="°C",
raw_mu=15.2,
raw_sigma=1.2,
deb_prediction=15.4,
ens_data={"p10": 14.8, "median": 15.8, "p90": 17.9},
current_forecasts={"ECMWF": 15.8, "GFS": 14.1},
max_so_far=15.0,
peak_status="before",
probabilities=[{"value": 15, "probability": 0.552}],
shadow_probabilities=[{"value": 15, "probability": 0.324}],
calibration_summary={
"engine": "legacy",
"mode": "emos_shadow",
"calibration_version": "emos-test",
"calibration_source": "artifacts/probability_calibration/default.json",
"calibrated_mu": 15.1,
"calibrated_sigma": 1.25,
},
)
payload = TrainingFeatureRecordRepository(db).get_record("ankara", "2026-03-20")
assert payload is not None
assert payload["mu"] == 15.2
assert payload["forecasts"]["ECMWF"] == 15.8
assert payload["probability_features"]["ens_median"] == 15.8
@@ -31,9 +31,22 @@ def test_extract_samples_prefers_snapshot_rows_for_same_city_day():
"peak_status": "in_window", "peak_status": "in_window",
} }
] ]
truth_history = {
"ankara": {
"2026-03-19": {
"actual_high": 11.0,
"settlement_source": "metar",
"settlement_station_code": "LTAC",
"truth_version": "v1",
"updated_by": "test",
"truth_updated_at": 123.0,
}
}
}
samples, filled = _extract_samples( samples, filled = _extract_samples(
history, history,
truth_history=truth_history,
settlement_history={}, settlement_history={},
snapshot_rows=snapshot_rows, snapshot_rows=snapshot_rows,
) )
@@ -43,3 +56,6 @@ def test_extract_samples_prefers_snapshot_rows_for_same_city_day():
assert samples[0]["sample_source"] == "snapshot" assert samples[0]["sample_source"] == "snapshot"
assert samples[0]["raw_mu"] == 11.2 assert samples[0]["raw_mu"] == 11.2
assert samples[0]["peak_flag"] == 0.5 assert samples[0]["peak_flag"] == 0.5
assert samples[0]["settlement_source"] == "metar"
assert samples[0]["settlement_station_code"] == "LTAC"
assert samples[0]["truth_version"] == "v1"
+64
View File
@@ -7,6 +7,9 @@ from src.database.runtime_state import (
ProbabilitySnapshotRepository, ProbabilitySnapshotRepository,
RuntimeStateDB, RuntimeStateDB,
TelegramAlertStateRepository, TelegramAlertStateRepository,
TrainingFeatureRecordRepository,
TruthRecordRepository,
TruthRevisionRepository,
) )
@@ -72,3 +75,64 @@ def test_open_meteo_cache_repository_roundtrip(tmp_path, monkeypatch):
loaded = repo.load_payload(86400) loaded = repo.load_payload(86400)
assert loaded['forecast']['ankara']['temp'] == 15 assert loaded['forecast']['ankara']['temp'] == 15
assert loaded['ensemble']['ankara']['spread'] == 1.5 assert loaded['ensemble']['ankara']['spread'] == 1.5
def test_truth_record_repository_tracks_revisions(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
db = RuntimeStateDB(str(tmp_path / 'polyweather.db'))
truth_repo = TruthRecordRepository(db)
revision_repo = TruthRevisionRepository(db)
changed = truth_repo.upsert_truth(
city='taipei',
target_date='2026-04-01',
actual_high=18.0,
settlement_source='wunderground',
settlement_station_code='RCSS',
settlement_station_label='Taipei Songshan Airport Station',
truth_version='v1',
updated_by='test:first',
source_payload={'raw_max_temp_c': 18.4},
reason='initial',
)
assert changed is True
assert revision_repo.load_revisions('taipei', '2026-04-01') == []
changed = truth_repo.upsert_truth(
city='taipei',
target_date='2026-04-01',
actual_high=19.0,
settlement_source='wunderground',
settlement_station_code='RCSS',
settlement_station_label='Taipei Songshan Airport Station',
truth_version='v1',
updated_by='test:second',
source_payload={'raw_max_temp_c': 19.1},
reason='correction',
)
assert changed is True
loaded = truth_repo.get_record('taipei', '2026-04-01')
assert loaded['actual_high'] == 19.0
revisions = revision_repo.load_revisions('taipei', '2026-04-01')
assert len(revisions) == 1
assert revisions[0]['previous_actual_high'] == 18.0
assert revisions[0]['next_actual_high'] == 19.0
def test_training_feature_record_repository_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
db = RuntimeStateDB(str(tmp_path / 'polyweather.db'))
repo = TrainingFeatureRecordRepository(db)
repo.upsert_record(
'ankara',
'2026-03-20',
{
'forecasts': {'ECMWF': 12.3},
'deb_prediction': 12.1,
'mu': 12.0,
'probability_features': {'ens_median': 12.2},
},
)
loaded = repo.get_record('ankara', '2026-03-20')
assert loaded['forecasts']['ECMWF'] == 12.3
assert loaded['deb_prediction'] == 12.1
+7
View File
@@ -23,12 +23,19 @@ def test_system_status_returns_summary_shape():
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert 'db' in payload assert 'db' in payload
assert 'state_storage_mode' in payload
assert 'features' in payload assert 'features' in payload
assert 'integrations' in payload assert 'integrations' in payload
assert 'cache' in payload assert 'cache' in payload
assert 'probability' in payload assert 'probability' in payload
assert 'rollout' in payload['probability'] assert 'rollout' in payload['probability']
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'} assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
assert 'training_data' in payload
assert 'truth_records' in payload['training_data']
assert 'training_features' in payload['training_data']
assert 'city_coverage' in payload['training_data']
assert 'model_city_coverage' in payload['training_data']
assert 'artifacts' in payload['training_data']
assert 'cities_count' in payload assert 'cities_count' in payload
+44
View File
@@ -0,0 +1,44 @@
from src.data_collection.wunderground_sources import fetch_wunderground_historical_high
class _FakeResponse:
def __init__(self, text: str):
self.text = text
def raise_for_status(self):
return None
class _FakeSession:
def __init__(self, text: str):
self._text = text
def get(self, url, headers=None, timeout=None):
return _FakeResponse(self._text)
def test_fetch_wunderground_historical_high_parses_daily_max():
html = """
<html><body>
<script id="app-root-state" type="application/json">
{
"x": {
"history": [
{"validTimeLocal": "2026-04-05T09:00:00+0800", "metric": {"temp": 24.4}},
{"validTimeLocal": "2026-04-05T13:00:00+0800", "metric": {"temp": 28.6}},
{"validTimeLocal": "2026-04-05T15:00:00+0800", "metric": {"temp": 27.9}}
]
}
}
</script>
</body></html>
"""
result = fetch_wunderground_historical_high(
"taipei",
"2026-04-05",
session=_FakeSession(html),
)
assert result["ok"] is True
assert result["settlement_source"] == "wunderground"
assert result["settlement_station_code"] == "RCSS"
assert result["actual_high"] == 29.0
+291
View File
@@ -2,6 +2,7 @@
PolyWeather Web Core Context PolyWeather Web Core Context
""" """
import json
import os import os
import sqlite3 import sqlite3
import time import time
@@ -135,6 +136,18 @@ _PROBABILITY_SHADOW_REPORT = os.path.join(
"probability_calibration", "probability_calibration",
"shadow_report.json", "shadow_report.json",
) )
_PROBABILITY_TRAINING_SAMPLES = os.path.join(
_PROJECT_ROOT,
"artifacts",
"probability_calibration",
"training_samples.json",
)
_LGBM_SCHEMA_REPORT = os.path.join(
_PROJECT_ROOT,
"artifacts",
"models",
"lgbm_daily_high_schema.json",
)
def _env_bool(name: str, default: bool = False) -> bool: def _env_bool(name: str, default: bool = False) -> bool:
@@ -436,6 +449,282 @@ def _probability_summary() -> Dict[str, Any]:
} }
def _read_json_file(path: str) -> Optional[Dict[str, Any]]:
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as fh:
payload = json.load(fh)
if isinstance(payload, dict):
return payload
except Exception:
return None
return None
def _table_date_summary(conn: sqlite3.Connection, table_name: str) -> Dict[str, Any]:
try:
row = conn.execute(
f"""
SELECT COUNT(*) AS row_count,
COUNT(DISTINCT city) AS cities_count,
MIN(target_date) AS min_date,
MAX(target_date) AS max_date
FROM {table_name}
"""
).fetchone()
except Exception as exc:
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
return {
"ok": True,
"row_count": int(row["row_count"] or 0),
"cities_count": int(row["cities_count"] or 0),
"min_date": row["min_date"],
"max_date": row["max_date"],
}
def _truth_source_counts(conn: sqlite3.Connection) -> Dict[str, int]:
try:
rows = conn.execute(
"""
SELECT COALESCE(NULLIF(TRIM(settlement_source), ''), 'unknown') AS settlement_source,
COUNT(*) AS row_count
FROM truth_records_store
GROUP BY COALESCE(NULLIF(TRIM(settlement_source), ''), 'unknown')
ORDER BY row_count DESC, settlement_source ASC
"""
).fetchall()
except Exception:
return {}
return {str(row["settlement_source"]): int(row["row_count"] or 0) for row in rows}
def _truth_revisions_summary(conn: sqlite3.Connection) -> Dict[str, Any]:
try:
row = conn.execute(
"""
SELECT COUNT(*) AS row_count,
MAX(updated_at) AS last_updated_at
FROM truth_revisions_store
"""
).fetchone()
except Exception as exc:
return {"ok": False, "error": str(exc), "row_count": 0}
return {
"ok": True,
"row_count": int(row["row_count"] or 0),
"last_updated_at": row["last_updated_at"],
}
def _city_coverage_summary(conn: sqlite3.Connection) -> Dict[str, Any]:
truth_rows = conn.execute(
"""
SELECT city, COUNT(*) AS row_count, MIN(target_date) AS min_date, MAX(target_date) AS max_date
FROM truth_records_store
GROUP BY city
"""
).fetchall()
feature_rows = conn.execute(
"""
SELECT city, COUNT(*) AS row_count, MIN(target_date) AS min_date, MAX(target_date) AS max_date
FROM training_feature_records_store
GROUP BY city
"""
).fetchall()
truth_index = {
str(row["city"]): {
"truth_rows": int(row["row_count"] or 0),
"truth_min_date": row["min_date"],
"truth_max_date": row["max_date"],
}
for row in truth_rows
}
feature_index = {
str(row["city"]): {
"feature_rows": int(row["row_count"] or 0),
"feature_min_date": row["min_date"],
"feature_max_date": row["max_date"],
}
for row in feature_rows
}
entries = []
for city, meta in CITY_REGISTRY.items():
truth_payload = truth_index.get(city, {})
feature_payload = feature_index.get(city, {})
entries.append(
{
"city": city,
"name": str(meta.get("name") or city),
"settlement_source": str(meta.get("settlement_source") or "metar"),
"settlement_station_code": str(meta.get("settlement_station_code") or meta.get("icao") or ""),
"truth_rows": int(truth_payload.get("truth_rows") or 0),
"feature_rows": int(feature_payload.get("feature_rows") or 0),
"truth_min_date": truth_payload.get("truth_min_date"),
"truth_max_date": truth_payload.get("truth_max_date"),
"feature_min_date": feature_payload.get("feature_min_date"),
"feature_max_date": feature_payload.get("feature_max_date"),
}
)
highlighted = [
entry
for entry in entries
if entry["city"] in {"taipei", "shenzhen"}
]
gaps = sorted(
entries,
key=lambda entry: (
entry["feature_rows"] > 0,
entry["truth_rows"] > 0,
entry["truth_rows"],
entry["feature_rows"],
entry["city"],
),
)[:10]
return {
"total_cities": len(entries),
"with_truth_rows": sum(1 for entry in entries if entry["truth_rows"] > 0),
"with_feature_rows": sum(1 for entry in entries if entry["feature_rows"] > 0),
"entries": entries,
"highlighted": highlighted,
"top_gaps": gaps,
}
def _model_city_coverage_summary(
city_entries: Any,
training_samples_payload: Dict[str, Any],
evaluation_report: Dict[str, Any],
) -> Dict[str, Any]:
training_samples = training_samples_payload.get("samples") or []
emos_training_counts: Dict[str, int] = {}
emos_snapshot_counts: Dict[str, int] = {}
for item in training_samples:
if not isinstance(item, dict):
continue
city = str(item.get("city") or "").strip().lower()
if not city:
continue
emos_training_counts[city] = emos_training_counts.get(city, 0) + 1
if str(item.get("sample_source") or "").strip().lower() == "snapshot":
emos_snapshot_counts[city] = emos_snapshot_counts.get(city, 0) + 1
evaluation_by_city = (evaluation_report.get("by_city") or {}) if isinstance(evaluation_report, dict) else {}
rows = []
for entry in city_entries or []:
city = str(entry.get("city") or "").strip().lower()
emos_eval = evaluation_by_city.get(city) or {}
rows.append(
{
"city": city,
"name": entry.get("name") or city,
"settlement_source": entry.get("settlement_source"),
"truth_rows": int(entry.get("truth_rows") or 0),
"feature_rows": int(entry.get("feature_rows") or 0),
"emos_training_samples": int(emos_training_counts.get(city, 0)),
"emos_snapshot_samples": int(emos_snapshot_counts.get(city, 0)),
"emos_evaluation_samples": int(emos_eval.get("samples") or 0),
"emos_delta_crps": emos_eval.get("emos_mean_crps"),
"lgbm_candidate_rows": int(entry.get("feature_rows") or 0),
}
)
weakest = sorted(
rows,
key=lambda row: (
row["emos_training_samples"] > 0,
row["lgbm_candidate_rows"] > 0,
row["truth_rows"] > 0,
row["emos_training_samples"],
row["lgbm_candidate_rows"],
row["truth_rows"],
row["city"],
),
)[:12]
strongest = sorted(
rows,
key=lambda row: (
-row["emos_training_samples"],
-row["lgbm_candidate_rows"],
-row["truth_rows"],
row["city"],
),
)[:8]
return {
"cities_with_emos_training": sum(1 for row in rows if row["emos_training_samples"] > 0),
"cities_with_lgbm_candidates": sum(1 for row in rows if row["lgbm_candidate_rows"] > 0),
"weakest": weakest,
"strongest": strongest,
}
def _training_data_summary() -> Dict[str, Any]:
db_path = _account_db.db_path
truth_records = {"ok": False, "row_count": 0, "cities_count": 0}
truth_revisions = {"ok": False, "row_count": 0}
training_features = {"ok": False, "row_count": 0, "cities_count": 0}
try:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
truth_records = _table_date_summary(conn, "truth_records_store")
if truth_records.get("ok"):
truth_records["source_counts"] = _truth_source_counts(conn)
truth_revisions = _truth_revisions_summary(conn)
training_features = _table_date_summary(conn, "training_feature_records_store")
city_coverage = _city_coverage_summary(conn)
except Exception as exc:
return {
"db_path": db_path,
"db_ok": False,
"error": str(exc),
"truth_records": truth_records,
"truth_revisions": truth_revisions,
"training_features": training_features,
"city_coverage": {},
"artifacts": {},
}
evaluation_report = _read_json_file(_PROBABILITY_EVALUATION_REPORT) or {}
shadow_report = _read_json_file(_PROBABILITY_SHADOW_REPORT) or {}
training_samples = _read_json_file(_PROBABILITY_TRAINING_SAMPLES) or {}
lgbm_report = _read_json_file(_LGBM_SCHEMA_REPORT) or {}
evaluation_summary = (evaluation_report.get("summary") or {}) if isinstance(evaluation_report, dict) else {}
shadow_summary = shadow_report.get("summary") or {}
lgbm_validation = ((lgbm_report.get("metrics") or {}).get("validation") or {})
return {
"db_path": db_path,
"db_ok": True,
"truth_records": truth_records,
"truth_revisions": truth_revisions,
"training_features": training_features,
"city_coverage": city_coverage,
"model_city_coverage": _model_city_coverage_summary(
city_coverage.get("entries") or [],
training_samples,
evaluation_report,
),
"artifacts": {
"emos_training_samples": training_samples.get("sample_count"),
"emos_snapshot_samples": training_samples.get("snapshot_sample_count"),
"emos_daily_record_samples": training_samples.get("daily_record_sample_count"),
"emos_evaluation_samples": evaluation_summary.get("sample_count"),
"emos_shadow_samples": shadow_summary.get("sample_count"),
"emos_delta_crps": (evaluation_summary.get("delta") or {}).get("crps"),
"lgbm_sample_count": lgbm_report.get("sample_count"),
"lgbm_train_count": lgbm_report.get("train_count"),
"lgbm_validation_count": lgbm_report.get("validation_count"),
"lgbm_validation_mae": lgbm_validation.get("lgbm_mae"),
"lgbm_validation_deb_mae": lgbm_validation.get("deb_mae"),
},
}
def build_health_payload() -> Dict[str, Any]: def build_health_payload() -> Dict[str, Any]:
db = _sqlite_health() db = _sqlite_health()
return { return {
@@ -451,11 +740,13 @@ def build_system_status_payload() -> Dict[str, Any]:
return { return {
"status": build_health_payload()["status"], "status": build_health_payload()["status"],
"time_utc": datetime.now(timezone.utc).isoformat(), "time_utc": datetime.now(timezone.utc).isoformat(),
"state_storage_mode": get_state_storage_mode(),
"db": _sqlite_health(), "db": _sqlite_health(),
"features": _feature_flags_summary(), "features": _feature_flags_summary(),
"integrations": _integration_summary(), "integrations": _integration_summary(),
"cache": _cache_summary(), "cache": _cache_summary(),
"metrics": build_metrics_summary(), "metrics": build_metrics_summary(),
"probability": _probability_summary(), "probability": _probability_summary(),
"training_data": _training_data_summary(),
"cities_count": len(CITIES), "cities_count": len(CITIES),
} }