Add ops dashboards for training data and model coverage
This commit is contained in:
+1122
-715
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user