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"
],
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 29,
"train_count": 17,
"sample_count": 54,
"train_count": 42,
"validation_count": 12,
"metrics": {
"validation": {
"sample_count": 12,
"lgbm_mae": 2.975,
"deb_mae": 2.267,
"best_single_mae": 1.167,
"median_mae": 2.483
"lgbm_mae": 1.349,
"deb_mae": 0.875,
"best_single_mae": 0.325,
"median_mae": 0.758
},
"full_sample": {
"sample_count": 29,
"lgbm_mae": 1.442,
"deb_mae": 3.448,
"best_single_mae": 2.486,
"median_mae": 3.628
"sample_count": 54,
"lgbm_mae": 0.691,
"deb_mae": 6.287,
"best_single_mae": 5.431,
"median_mae": 6.265
}
},
"generated_at": "2026-03-29T14:57:35.832414Z",
"trained_at": "2026-03-29T14:57:35.832414Z"
"generated_at": "2026-04-02T16:27:44.816882Z",
"trained_at": "2026-04-02T16:27:44.816882Z"
}
+34 -34
View File
@@ -1,20 +1,20 @@
{
"version": "emos-20260402151301",
"trained_at": "2026-04-02T15:13:01.073253+00:00",
"version": "emos-20260402162744",
"trained_at": "2026-04-02T16:27:44.114836+00:00",
"global": {
"mu": {
"intercept": 1.52725527,
"raw_mu_coef": 2.96318139,
"deb_coef": -1.54490032,
"ens_median_coef": -0.71820793,
"max_so_far_gap_coef": 10.22802132
"intercept": 1.54512641,
"raw_mu_coef": 2.96105052,
"deb_coef": -1.53260815,
"ens_median_coef": -0.72849343,
"max_so_far_gap_coef": 9.52557689
},
"sigma": {
"intercept": 0.67227661,
"raw_sigma_coef": 0.69444674,
"spread_coef": 0.09134583,
"peak_flag_coef": -0.58201761,
"max_so_far_gap_coef": -0.81482466
"intercept": 0.67432479,
"raw_sigma_coef": 0.6936692,
"spread_coef": 0.08877484,
"peak_flag_coef": -0.58374835,
"max_so_far_gap_coef": -0.8172477
}
},
"sigma_constraints": {
@@ -30,60 +30,60 @@
},
"blending": {
"alpha_mu": 0.0,
"alpha_sigma": 0.45
"alpha_sigma": 0.05
},
"cities": {
"ankara": {
"samples": 3,
"mu_bias": 1.283918,
"sigma_scale": 1.484056,
"mu_bias": 1.271844,
"sigma_scale": 1.477644,
"confidence": 0.375
},
"hong kong": {
"samples": 4,
"mu_bias": 1.329042,
"sigma_scale": 1.045379,
"mu_bias": 1.32008,
"sigma_scale": 1.04023,
"confidence": 0.5
},
"milan": {
"samples": 3,
"mu_bias": -3.932299,
"mu_bias": -3.935178,
"sigma_scale": 2.0,
"confidence": 0.375
},
"shanghai": {
"samples": 3,
"mu_bias": 1.827618,
"mu_bias": 1.810495,
"sigma_scale": 2.0,
"confidence": 0.375
},
"taipei": {
"samples": 3,
"mu_bias": 3.387896,
"mu_bias": 3.577828,
"sigma_scale": 2.0,
"confidence": 0.375
},
"warsaw": {
"samples": 3,
"mu_bias": -0.614031,
"sigma_scale": 1.252706,
"mu_bias": -0.625333,
"sigma_scale": 1.25968,
"confidence": 0.375
}
},
"metrics": {
"sample_count": 54,
"mean_crps": 3.78558,
"legacy_mean_crps": 4.326547,
"legacy_mean_mae": 4.528889,
"legacy_bucket_hit_rate": 0.518519,
"legacy_bucket_brier": 0.83496,
"selected_mean_crps": 4.239815,
"selected_mean_mae": 4.528889,
"selected_bucket_hit_rate": 0.537037,
"selected_bucket_brier": 0.853485,
"selected_score": 6.027262,
"legacy_score": 6.127879,
"filled_actual_from_history": 2,
"mean_crps": 3.792563,
"legacy_mean_crps": 4.308029,
"legacy_mean_mae": 4.51037,
"legacy_bucket_hit_rate": 0.537037,
"legacy_bucket_brier": 0.833294,
"selected_mean_crps": 4.249828,
"selected_mean_mae": 4.51037,
"selected_bucket_hit_rate": 0.555556,
"selected_bucket_brier": 0.831872,
"selected_score": 5.991436,
"legacy_score": 6.078481,
"filled_actual_from_history": 0,
"settlement_history_city_count": 30
},
"source": "artifacts\\probability_calibration\\default.json"
@@ -3,17 +3,17 @@
"sample_count": 54,
"filled_actual_from_history": 2,
"legacy": {
"mean_crps": 4.287017,
"mean_mae": 4.488148,
"mean_crps": 4.300621,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
},
"emos": {
"mean_crps": 4.201116,
"mean_mae": 4.488148,
"mean_crps": 4.213889,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
},
"delta": {
"crps": -0.085901,
"crps": -0.086732,
"mae": 0.0,
"bucket_hit_rate": 0.0
}
@@ -183,12 +183,12 @@
},
"shenzhen": {
"samples": 1,
"legacy_mean_crps": 0.063696,
"emos_mean_crps": 0.073016,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"legacy_mean_crps": 0.798351,
"emos_mean_crps": 0.762787,
"legacy_mean_mae": 0.9,
"emos_mean_mae": 0.9,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"singapore": {
"samples": 2,
@@ -205,8 +205,8 @@
"emos_mean_crps": 0.472738,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.666667
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"tel aviv": {
"samples": 2,
@@ -17,55 +17,57 @@
"max_delta_bucket_brier_observe": 0.15
},
"evaluation": {
"sample_count": 105,
"delta_crps": -0.093663,
"sample_count": 54,
"delta_crps": -0.086732,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0
},
"shadow": {
"sample_count": 103,
"delta_mae": 0.012708,
"delta_bucket_hit_rate": 0.009709,
"delta_bucket_brier": 0.293835
"sample_count": 48,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.041666,
"delta_bucket_brier": 0.123252
},
"blocking_reasons": [
"shadow bucket brier 退化超限:delta=0.293835"
"离线评估样本不足:54 < 80",
"shadow 样本不足:48 < 50",
"shadow bucket brier 退化超限:delta=0.123252"
],
"worst_shadow_regressions": [
{
"city": "dallas",
"samples": 4,
"delta_mae": 0.114807,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.778678
"delta_bucket_brier": 0.792585
},
{
"city": "chicago",
"samples": 4,
"delta_mae": 0.075265,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.746156
"delta_bucket_brier": 0.791878
},
{
"city": "seattle",
"samples": 4,
"delta_mae": 0.11262,
"samples": 1,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.692003
"delta_bucket_brier": 0.61609
},
{
"city": "atlanta",
"samples": 4,
"delta_mae": 0.293028,
"delta_bucket_hit_rate": -0.25,
"delta_bucket_brier": 0.601425
"city": "wellington",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.509203
},
{
"city": "miami",
"samples": 4,
"delta_mae": 0.241559,
"delta_bucket_hit_rate": -0.5,
"delta_bucket_brier": 0.478245
"city": "tel aviv",
"samples": 2,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"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": {
"samples": 48,
"legacy_mean_mae": 3.04125,
"shadow_mean_mae": 3.04125,
"legacy_bucket_hit_rate": 0.479167,
"shadow_bucket_hit_rate": 0.520833,
"legacy_bucket_brier": 0.688077,
"shadow_bucket_brier": 0.811329,
"legacy_bucket_hit_rate": 0.5,
"shadow_bucket_hit_rate": 0.5,
"legacy_bucket_brier": 0.68666,
"shadow_bucket_brier": 0.814079,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.041666,
"delta_bucket_brier": 0.123252
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.127419
},
"by_city": {
"ankara": {
@@ -245,13 +245,13 @@
"samples": 3,
"legacy_mean_mae": 0.1,
"shadow_mean_mae": 0.1,
"legacy_bucket_hit_rate": 0.666667,
"shadow_bucket_hit_rate": 0.666667,
"legacy_bucket_brier": 0.217927,
"shadow_bucket_brier": 0.640998,
"legacy_bucket_hit_rate": 1.0,
"shadow_bucket_hit_rate": 0.333333,
"legacy_bucket_brier": 0.195261,
"shadow_bucket_brier": 0.684998,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.0,
"delta_bucket_brier": 0.423071
"delta_bucket_hit_rate": -0.666667,
"delta_bucket_brier": 0.489737
},
"tel aviv": {
"samples": 2,
@@ -343,13 +343,13 @@
"samples": 19,
"legacy_mean_mae": 4.299474,
"shadow_mean_mae": 4.299474,
"legacy_bucket_hit_rate": 0.315789,
"shadow_bucket_hit_rate": 0.473684,
"legacy_bucket_brier": 0.85177,
"shadow_bucket_brier": 0.928731,
"legacy_bucket_hit_rate": 0.368421,
"shadow_bucket_hit_rate": 0.421053,
"legacy_bucket_brier": 0.848191,
"shadow_bucket_brier": 0.935678,
"delta_mae": 0.0,
"delta_bucket_hit_rate": 0.157895,
"delta_bucket_brier": 0.076961
"delta_bucket_hit_rate": 0.052632,
"delta_bucket_brier": 0.087487
}
},
"recent_observations": [
@@ -416,8 +416,8 @@
{
"city": "taipei",
"date": "2026-03-19",
"actual_high": 21.4,
"actual_bucket": 21,
"actual_high": 22.0,
"actual_bucket": 22,
"legacy_mu": 21.7,
"shadow_mu": 21.7,
"legacy_top_bucket": 22,
@@ -8,7 +8,7 @@
"city": "shenzhen",
"date": "2026-03-25",
"timestamp": "2026-03-25T08:57:11.783182+00:00",
"actual_high": 29.0,
"actual_high": 28.0,
"raw_mu": 26.7,
"raw_sigma": 0.18016764322916676,
"deb_prediction": 28.1,
@@ -16,7 +16,12 @@
"ensemble_spread": 0.5078125000000002,
"max_so_far_gap": 1.4000000000000021,
"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",
@@ -29,7 +34,12 @@
"ensemble_spread": 1.2000000000000002,
"max_so_far_gap": null,
"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",
@@ -42,7 +52,12 @@
"ensemble_spread": 2.05,
"max_so_far_gap": null,
"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",
@@ -55,7 +70,12 @@
"ensemble_spread": 0.9000000000000004,
"max_so_far_gap": null,
"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",
@@ -68,7 +88,12 @@
"ensemble_spread": 4.0,
"max_so_far_gap": null,
"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",
@@ -81,7 +106,12 @@
"ensemble_spread": 1.5500000000000007,
"max_so_far_gap": null,
"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",
@@ -94,7 +124,12 @@
"ensemble_spread": 2.0,
"max_so_far_gap": null,
"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",
@@ -107,7 +142,12 @@
"ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null,
"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",
@@ -120,7 +160,12 @@
"ensemble_spread": 5.350000000000001,
"max_so_far_gap": null,
"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",
@@ -133,7 +178,12 @@
"ensemble_spread": 9.299999999999997,
"max_so_far_gap": null,
"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",
@@ -146,7 +196,12 @@
"ensemble_spread": 1.9000000000000004,
"max_so_far_gap": null,
"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",
@@ -159,7 +214,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -172,7 +232,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -185,7 +250,12 @@
"ensemble_spread": 1.6999999999999993,
"max_so_far_gap": null,
"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",
@@ -198,7 +268,12 @@
"ensemble_spread": 1.299999999999999,
"max_so_far_gap": null,
"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",
@@ -211,7 +286,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -224,7 +304,12 @@
"ensemble_spread": 1.3000000000000007,
"max_so_far_gap": null,
"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",
@@ -237,7 +322,12 @@
"ensemble_spread": 1.75,
"max_so_far_gap": null,
"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",
@@ -250,7 +340,12 @@
"ensemble_spread": 1.8499999999999996,
"max_so_far_gap": null,
"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",
@@ -263,7 +358,12 @@
"ensemble_spread": 1.8999999999999995,
"max_so_far_gap": null,
"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",
@@ -276,7 +376,12 @@
"ensemble_spread": 2.8999999999999986,
"max_so_far_gap": null,
"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",
@@ -289,7 +394,12 @@
"ensemble_spread": 2.5500000000000043,
"max_so_far_gap": null,
"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",
@@ -302,7 +412,12 @@
"ensemble_spread": 9.1,
"max_so_far_gap": null,
"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",
@@ -315,7 +430,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -328,7 +448,12 @@
"ensemble_spread": 1.25,
"max_so_far_gap": null,
"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",
@@ -341,7 +466,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -354,7 +484,12 @@
"ensemble_spread": 1.3000000000000007,
"max_so_far_gap": null,
"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",
@@ -367,7 +502,12 @@
"ensemble_spread": 2.25,
"max_so_far_gap": null,
"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",
@@ -380,7 +520,12 @@
"ensemble_spread": 0.9000000000000004,
"max_so_far_gap": null,
"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",
@@ -393,7 +538,12 @@
"ensemble_spread": 0.6,
"max_so_far_gap": null,
"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",
@@ -406,7 +556,12 @@
"ensemble_spread": 2.450000000000001,
"max_so_far_gap": null,
"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",
@@ -419,7 +574,12 @@
"ensemble_spread": 1.200000000000001,
"max_so_far_gap": null,
"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",
@@ -432,7 +592,12 @@
"ensemble_spread": 1.3500000000000014,
"max_so_far_gap": null,
"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",
@@ -445,7 +610,12 @@
"ensemble_spread": 0.7999999999999998,
"max_so_far_gap": null,
"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",
@@ -458,7 +628,12 @@
"ensemble_spread": 1.7999999999999998,
"max_so_far_gap": null,
"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",
@@ -471,7 +646,12 @@
"ensemble_spread": 1.1500000000000004,
"max_so_far_gap": null,
"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",
@@ -484,7 +664,12 @@
"ensemble_spread": 0.7999999999999998,
"max_so_far_gap": null,
"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",
@@ -497,7 +682,12 @@
"ensemble_spread": 1.6999999999999993,
"max_so_far_gap": null,
"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",
@@ -510,7 +700,12 @@
"ensemble_spread": 0.9500000000000011,
"max_so_far_gap": null,
"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",
@@ -523,7 +718,12 @@
"ensemble_spread": 1.3499999999999996,
"max_so_far_gap": null,
"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",
@@ -536,7 +736,12 @@
"ensemble_spread": 2.25,
"max_so_far_gap": null,
"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",
@@ -549,12 +754,17 @@
"ensemble_spread": 1.0500000000000007,
"max_so_far_gap": null,
"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",
"date": "2026-03-19",
"actual_high": 21.4,
"actual_high": 22.0,
"raw_mu": 21.7,
"raw_sigma": 1.1500000000000004,
"deb_prediction": 21.5,
@@ -562,7 +772,12 @@
"ensemble_spread": 1.1500000000000004,
"max_so_far_gap": null,
"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",
@@ -575,7 +790,12 @@
"ensemble_spread": 2.1500000000000004,
"max_so_far_gap": null,
"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",
@@ -588,7 +808,12 @@
"ensemble_spread": 1.5,
"max_so_far_gap": null,
"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",
@@ -601,7 +826,12 @@
"ensemble_spread": 1.5499999999999998,
"max_so_far_gap": null,
"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",
@@ -614,7 +844,12 @@
"ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null,
"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",
@@ -627,7 +862,12 @@
"ensemble_spread": 0.8,
"max_so_far_gap": null,
"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",
@@ -640,7 +880,12 @@
"ensemble_spread": 2.1500000000000004,
"max_so_far_gap": null,
"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",
@@ -653,7 +898,12 @@
"ensemble_spread": 0.6499999999999995,
"max_so_far_gap": null,
"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",
@@ -666,7 +916,12 @@
"ensemble_spread": 1.4000000000000004,
"max_so_far_gap": null,
"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",
@@ -679,7 +934,12 @@
"ensemble_spread": 1.5999999999999996,
"max_so_far_gap": null,
"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",
@@ -692,7 +952,12 @@
"ensemble_spread": 0.9500000000000011,
"max_so_far_gap": null,
"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",
@@ -705,7 +970,12 @@
"ensemble_spread": 2.0999999999999996,
"max_so_far_gap": null,
"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>;
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 = {
@@ -140,6 +230,16 @@ function formatDateTime(value?: string | null) {
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) {
if (!value) return "-";
if (value.length <= 40) return value;
@@ -313,6 +413,36 @@ export function OpsDashboard() {
}, [incidentReasonFilter, load, loadPaymentIncidents]);
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 events = analyticsFunnel?.events || {};
@@ -682,6 +812,307 @@ export function OpsDashboard() {
</Card>
</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>
<CardHeader>
<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:
source = str(city_info.get("settlement_source") or "metar").strip().lower()
return source == "metar"
return source in {"metar", "hko", "noaa", "wunderground"}
def main() -> None:
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(
"--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.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
ProbabilitySnapshotRepository,
STATE_STORAGE_FILE,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode,
)
@@ -56,12 +59,34 @@ def _default_snapshot_arg():
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)
if data:
return data
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):
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return ProbabilitySnapshotRepository().load_all_rows()
@@ -82,18 +107,28 @@ def _load_snapshot_rows(path):
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 {}
record = city_rows.get(date_str) or {}
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
if actual_high is None:
actual_high = _sf(((settlement_history.get(city) or {}).get(date_str) or {}).get("max_temp"))
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 = []
filled_actual_from_history = 0
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:
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:
continue
if filled:
@@ -167,13 +208,20 @@ def _extract_snapshot_samples(history, snapshot_rows, settlement_history=None):
"max_so_far_gap": max_so_far_gap,
"peak_flag": peak_flag,
"sample_source": "snapshot",
**truth_meta,
}
)
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 = []
filled_actual_from_history = 0
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:
continue
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:
actual_high = _sf((city_settlement.get(date_str) or {}).get("max_temp"))
if actual_high is not None:
filled_actual_from_history += 1
deb_prediction = _sf(record.get("deb_prediction"))
raw_mu = _sf(record.get("mu")) or deb_prediction
forecasts = record.get("forecasts") or {}
feature_record = ((training_feature_history or {}).get(city) or {}).get(date_str) or {}
source_record = feature_record if isinstance(feature_record, dict) and feature_record else record
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):
forecasts = {}
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_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):
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,
"peak_flag": peak_flag,
"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
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(
history,
snapshot_rows or [],
truth_history=truth_history,
snapshot_rows=snapshot_rows or [],
settlement_history=settlement_history,
)
excluded_keys = {
@@ -260,6 +319,8 @@ def _extract_samples(history, settlement_history=None, snapshot_rows=None):
}
daily_samples, daily_filled = _extract_daily_record_samples(
history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history,
excluded_keys=excluded_keys,
)
@@ -301,10 +362,14 @@ def main():
args = parser.parse_args()
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)
snapshot_rows = _load_snapshot_rows(args.snapshot_file)
samples, filled_actual_from_history = _extract_samples(
history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history,
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 json
from datetime import datetime, timedelta
from typing import Optional
import requests
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 src.database.runtime_state import (
DailyRecordRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode,
)
@@ -44,6 +48,9 @@ else:
_history_cache = {}
_history_mtime = 0
_daily_record_repo = DailyRecordRepository()
_training_feature_repo = TrainingFeatureRecordRepository()
_truth_record_repo = TruthRecordRepository()
_TRUTH_VERSION = "v1"
def _sf(value):
@@ -168,6 +175,80 @@ def _resolve_city_history_context(city_name: str):
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):
if not isinstance(payload, dict):
return None
@@ -275,6 +356,15 @@ def _reconcile_recent_metar_actual_highs(city_name: str, lookback_days: int = 7)
if t_c is None:
continue
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 {}
old = rec.get("actual_high")
try:
@@ -367,6 +457,19 @@ def _reconcile_recent_hko_actual_highs(city_name: str, lookback_days: int = 14):
if use_fahrenheit
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 {}
old = rec.get("actual_high")
try:
@@ -486,6 +589,19 @@ def _reconcile_recent_noaa_actual_highs(city_name: str, lookback_days: int = 14)
if use_fahrenheit
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 {}
old = rec.get("actual_high")
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}
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):
"""
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)
if settlement_source == "noaa":
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)
@@ -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}
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}
icao = str(city_meta.get("icao") or "").strip().upper()
station_code = str(city_meta.get("settlement_station_code") or "").strip().upper()
if settlement_source == "metar" and not icao:
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}
tz_offset = int(city_meta.get("tz_offset") or 0)
@@ -726,6 +917,37 @@ def update_daily_record(
if compact_calibration is not None:
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 天留足余量)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
for city in list(data.keys()):
@@ -9,6 +9,7 @@ from src.database.runtime_state import (
ProbabilitySnapshotRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
get_state_storage_mode,
)
@@ -17,6 +18,7 @@ MU_THRESHOLD = 0.2
SIGMA_THRESHOLD = 0.15
MAX_SO_FAR_THRESHOLD = 0.2
_snapshot_repo = ProbabilitySnapshotRepository()
_training_feature_repo = TrainingFeatureRecordRepository()
def _sf(value: Any) -> Optional[float]:
@@ -260,6 +262,36 @@ def append_probability_snapshot(
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
_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:
with open(path, "a", encoding="utf-8") as fh:
@@ -5,8 +5,10 @@ import re
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import requests
from loguru import logger
from src.data_collection.city_registry import CITY_REGISTRY
from src.analysis.settlement_rounding import apply_city_settlement
class WundergroundSourceMixin:
@@ -502,3 +504,92 @@ class WundergroundSourceMixin:
}
self._set_settlement_cache(cache_key, 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(
"""
CREATE TABLE IF NOT EXISTS telegram_alert_last_by_city (
@@ -117,6 +159,20 @@ class RuntimeStateDB:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_probability_snapshot_city_date ON probability_training_snapshots_store(city, target_date, id DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS 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(
"""
CREATE TABLE IF NOT EXISTS open_meteo_cache_store (
@@ -238,6 +294,251 @@ class DailyRecordRepository:
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:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
@@ -429,6 +730,66 @@ class ProbabilitySnapshotRepository:
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:
def __init__(self, db: Optional[RuntimeStateDB] = None):
self.db = db or RuntimeStateDB.instance()
+84 -6
View File
@@ -13,6 +13,8 @@ from src.database.runtime_state import (
ProbabilitySnapshotRepository,
STATE_STORAGE_FILE,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
get_state_storage_mode,
)
@@ -288,23 +290,93 @@ def build_training_samples(
snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
if isinstance(history_data, dict):
data = history_data
runtime_history = history_data
elif get_state_storage_mode() == STATE_STORAGE_SQLITE:
data = DailyRecordRepository().load_all()
runtime_history = DailyRecordRepository().load_all()
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()
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):
continue
ordered_dates = sorted(city_records.keys())
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)
if not isinstance(record, dict):
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 {}
if target is None or not forecasts:
continue
@@ -321,7 +393,7 @@ def build_training_samples(
local_hour=12,
local_date=str(date_str),
peak_status="before",
history_data=data,
history_data=truth_history,
)
if not feature_map:
continue
@@ -361,6 +433,12 @@ def build_training_samples(
for key, value in forecasts.items()
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"]))
+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():
@@ -59,3 +59,35 @@ def test_build_runtime_feature_map_returns_none_without_history():
assert feature_map is None
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
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)
@@ -10,10 +11,22 @@ def _force_file_mode(monkeypatch):
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "file")
def test_append_probability_snapshot_writes_jsonl(tmp_path: Path):
def test_append_probability_snapshot_writes_jsonl(tmp_path: Path, monkeypatch):
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",
local_date="2026-03-20",
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),
)
append_probability_snapshot(**kwargs)
append_probability_snapshot(**kwargs)
snapshot_archive.append_probability_snapshot(**kwargs)
snapshot_archive.append_probability_snapshot(**kwargs)
lines = archive_path.read_text(encoding="utf-8").strip().splitlines()
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),
)
append_probability_snapshot(
snapshot_archive.append_probability_snapshot(
probabilities=[{"value": 15, "probability": 0.552}],
**base_kwargs,
)
append_probability_snapshot(
snapshot_archive.append_probability_snapshot(
probabilities=[{"value": 16, "probability": 0.552}],
**base_kwargs,
)
lines = archive_path.read_text(encoding="utf-8").strip().splitlines()
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",
}
]
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(
history,
truth_history=truth_history,
settlement_history={},
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]["raw_mu"] == 11.2
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,
RuntimeStateDB,
TelegramAlertStateRepository,
TrainingFeatureRecordRepository,
TruthRecordRepository,
TruthRevisionRepository,
)
@@ -72,3 +75,64 @@ def test_open_meteo_cache_repository_roundtrip(tmp_path, monkeypatch):
loaded = repo.load_payload(86400)
assert loaded['forecast']['ankara']['temp'] == 15
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
payload = response.json()
assert 'db' in payload
assert 'state_storage_mode' in payload
assert 'features' in payload
assert 'integrations' in payload
assert 'cache' in payload
assert 'probability' in payload
assert 'rollout' in payload['probability']
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
assert '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
+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
"""
import json
import os
import sqlite3
import time
@@ -135,6 +136,18 @@ _PROBABILITY_SHADOW_REPORT = os.path.join(
"probability_calibration",
"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:
@@ -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]:
db = _sqlite_health()
return {
@@ -451,11 +740,13 @@ def build_system_status_payload() -> Dict[str, Any]:
return {
"status": build_health_payload()["status"],
"time_utc": datetime.now(timezone.utc).isoformat(),
"state_storage_mode": get_state_storage_mode(),
"db": _sqlite_health(),
"features": _feature_flags_summary(),
"integrations": _integration_summary(),
"cache": _cache_summary(),
"metrics": build_metrics_summary(),
"probability": _probability_summary(),
"training_data": _training_data_summary(),
"cities_count": len(CITIES),
}