Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9823e3961f | |||
| 2598c5ac98 | |||
| e2cb0cfe5e | |||
| fd2b870d6a | |||
| 46b0e63ffd | |||
| 0eee5c8948 | |||
| 25424700ce | |||
| 767f7ed6bf | |||
| c0e5307893 | |||
| b635070106 | |||
| 2ead56f59d | |||
| 611e2d9009 | |||
| 53d2ae4c10 | |||
| 4bfd2534bb | |||
| df82234b66 | |||
| fcdece7ab4 | |||
| 2e81894fd1 | |||
| 26c4162f50 | |||
| 6249a00382 | |||
| da3acbbb6e |
+276
-1326
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": 54,
|
||||
"train_count": 42,
|
||||
"sample_count": 18,
|
||||
"train_count": 6,
|
||||
"validation_count": 12,
|
||||
"metrics": {
|
||||
"validation": {
|
||||
"sample_count": 12,
|
||||
"lgbm_mae": 1.349,
|
||||
"deb_mae": 0.875,
|
||||
"best_single_mae": 0.325,
|
||||
"median_mae": 0.758
|
||||
"lgbm_mae": 7.992,
|
||||
"deb_mae": 2.375,
|
||||
"best_single_mae": 1.05,
|
||||
"median_mae": 2.292
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 54,
|
||||
"lgbm_mae": 0.691,
|
||||
"deb_mae": 6.287,
|
||||
"best_single_mae": 5.431,
|
||||
"median_mae": 6.265
|
||||
"sample_count": 18,
|
||||
"lgbm_mae": 3.034,
|
||||
"deb_mae": 1.689,
|
||||
"best_single_mae": 0.822,
|
||||
"median_mae": 1.711
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-04-02T16:27:44.816882Z",
|
||||
"trained_at": "2026-04-02T16:27:44.816882Z"
|
||||
"generated_at": "2026-04-15T16:41:56.814421Z",
|
||||
"trained_at": "2026-04-15T16:41:56.814421Z"
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"version": "emos-20260402162744",
|
||||
"trained_at": "2026-04-02T16:27:44.114836+00:00",
|
||||
"version": "emos-20260415170349",
|
||||
"trained_at": "2026-04-15T17:03:49.122865+00:00",
|
||||
"global": {
|
||||
"mu": {
|
||||
"intercept": 1.54512641,
|
||||
"raw_mu_coef": 2.96105052,
|
||||
"deb_coef": -1.53260815,
|
||||
"ens_median_coef": -0.72849343,
|
||||
"max_so_far_gap_coef": 9.52557689
|
||||
"intercept": 2.45853035,
|
||||
"raw_mu_coef": 2.03689031,
|
||||
"deb_coef": -1.41207502,
|
||||
"ens_median_coef": 0.08616811,
|
||||
"max_so_far_gap_coef": -0.5886898
|
||||
},
|
||||
"sigma": {
|
||||
"intercept": 0.67432479,
|
||||
"raw_sigma_coef": 0.6936692,
|
||||
"spread_coef": 0.08877484,
|
||||
"peak_flag_coef": -0.58374835,
|
||||
"max_so_far_gap_coef": -0.8172477
|
||||
"intercept": 0.61260118,
|
||||
"raw_sigma_coef": -1.48747774,
|
||||
"spread_coef": 2.24562634,
|
||||
"peak_flag_coef": -0.15906747,
|
||||
"max_so_far_gap_coef": -0.01835251
|
||||
}
|
||||
},
|
||||
"sigma_constraints": {
|
||||
@@ -29,62 +29,81 @@
|
||||
"max_bucket_brier_increase": 0.05
|
||||
},
|
||||
"blending": {
|
||||
"alpha_mu": 0.0,
|
||||
"alpha_sigma": 0.05
|
||||
"alpha_mu": 0.05,
|
||||
"alpha_sigma": 1.0
|
||||
},
|
||||
"cities": {
|
||||
"tokyo": {
|
||||
"samples": 5,
|
||||
"mu_bias": 2.223946,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"ankara": {
|
||||
"samples": 6,
|
||||
"mu_bias": -0.396953,
|
||||
"sigma_scale": 1.094474,
|
||||
"confidence": 0.75
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.271844,
|
||||
"sigma_scale": 1.477644,
|
||||
"mu_bias": -0.163153,
|
||||
"sigma_scale": 0.5,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 4,
|
||||
"mu_bias": 1.32008,
|
||||
"sigma_scale": 1.04023,
|
||||
"confidence": 0.5
|
||||
"samples": 7,
|
||||
"mu_bias": 3.267553,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.875
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 5,
|
||||
"mu_bias": 4.80044,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.625
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 3,
|
||||
"mu_bias": -3.080962,
|
||||
"sigma_scale": 1.437353,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"mu_bias": -3.935178,
|
||||
"mu_bias": -4.20255,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"mu_bias": 1.810495,
|
||||
"sigma_scale": 2.0,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"mu_bias": 3.577828,
|
||||
"sigma_scale": 2.0,
|
||||
"mu_bias": 1.185335,
|
||||
"sigma_scale": 1.68386,
|
||||
"confidence": 0.375
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"mu_bias": -0.625333,
|
||||
"sigma_scale": 1.25968,
|
||||
"mu_bias": -1.574246,
|
||||
"sigma_scale": 1.343402,
|
||||
"confidence": 0.375
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"sample_count": 54,
|
||||
"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,
|
||||
"sample_count": 71,
|
||||
"mean_crps": 3.226613,
|
||||
"legacy_mean_crps": 3.608885,
|
||||
"legacy_mean_mae": 3.81507,
|
||||
"legacy_bucket_hit_rate": 0.492958,
|
||||
"legacy_bucket_brier": 0.873776,
|
||||
"selected_mean_crps": 3.44742,
|
||||
"selected_mean_mae": 3.749078,
|
||||
"selected_bucket_hit_rate": 0.492958,
|
||||
"selected_bucket_brier": 0.878621,
|
||||
"selected_score": 5.241857,
|
||||
"legacy_score": 5.406287,
|
||||
"filled_actual_from_history": 0,
|
||||
"settlement_history_city_count": 30
|
||||
"settlement_history_city_count": 30,
|
||||
"legacy_archive_samples": 54
|
||||
},
|
||||
"source": "artifacts\\probability_calibration\\default.json"
|
||||
}
|
||||
@@ -1,257 +1,284 @@
|
||||
{
|
||||
"summary": {
|
||||
"sample_count": 54,
|
||||
"filled_actual_from_history": 2,
|
||||
"sample_count": 71,
|
||||
"filled_actual_from_history": 0,
|
||||
"legacy": {
|
||||
"mean_crps": 4.300621,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
"mean_crps": 3.608885,
|
||||
"mean_mae": 3.81507,
|
||||
"bucket_hit_rate": 0.492958
|
||||
},
|
||||
"emos": {
|
||||
"mean_crps": 4.213889,
|
||||
"mean_mae": 4.502963,
|
||||
"bucket_hit_rate": 0.537037
|
||||
"mean_crps": 3.881798,
|
||||
"mean_mae": 4.378282,
|
||||
"bucket_hit_rate": 0.225352
|
||||
},
|
||||
"delta": {
|
||||
"crps": -0.086732,
|
||||
"mae": 0.0,
|
||||
"bucket_hit_rate": 0.0
|
||||
"crps": 0.272913,
|
||||
"mae": 0.563211,
|
||||
"bucket_hit_rate": -0.267606
|
||||
}
|
||||
},
|
||||
"by_city": {
|
||||
"ankara": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.327701,
|
||||
"emos_mean_crps": 0.439705,
|
||||
"legacy_mean_mae": 0.066667,
|
||||
"emos_mean_mae": 0.066667,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 6,
|
||||
"legacy_mean_crps": 1.365135,
|
||||
"emos_mean_crps": 1.454394,
|
||||
"legacy_mean_mae": 1.466667,
|
||||
"emos_mean_mae": 1.781865,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"atlanta": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 30.449382,
|
||||
"emos_mean_crps": 30.578432,
|
||||
"emos_mean_crps": 30.2136,
|
||||
"legacy_mean_mae": 32.015,
|
||||
"emos_mean_mae": 32.015,
|
||||
"emos_mean_mae": 31.431544,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"buenos aires": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 9.113412,
|
||||
"emos_mean_crps": 8.759954,
|
||||
"emos_mean_crps": 9.25091,
|
||||
"legacy_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.27,
|
||||
"emos_mean_mae": 10.234011,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"busan": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.451701,
|
||||
"emos_mean_crps": 1.176676,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 1.887548,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"chengdu": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.306469,
|
||||
"emos_mean_crps": 0.555327,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.929502,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"chicago": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 1.250268,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"emos_mean_crps": 2.090175,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 3.396557,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"dallas": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 2.173363,
|
||||
"emos_mean_crps": 0.701085,
|
||||
"emos_mean_crps": 1.05572,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 1.653475,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"hong kong": {
|
||||
"samples": 4,
|
||||
"legacy_mean_crps": 0.29509,
|
||||
"emos_mean_crps": 0.387946,
|
||||
"legacy_mean_mae": 0.075,
|
||||
"emos_mean_mae": 0.075,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 0.75
|
||||
"samples": 7,
|
||||
"legacy_mean_crps": 0.706099,
|
||||
"emos_mean_crps": 1.573367,
|
||||
"legacy_mean_mae": 0.657143,
|
||||
"emos_mean_mae": 1.88803,
|
||||
"legacy_bucket_hit_rate": 0.714286,
|
||||
"emos_bucket_hit_rate": 0.142857
|
||||
},
|
||||
"istanbul": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.264103,
|
||||
"emos_mean_crps": 1.266844,
|
||||
"legacy_mean_mae": 1.5,
|
||||
"emos_mean_mae": 1.415758,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"london": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.885033,
|
||||
"emos_mean_crps": 3.866915,
|
||||
"emos_mean_crps": 4.057521,
|
||||
"legacy_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.135,
|
||||
"emos_mean_mae": 4.313103,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"lucknow": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 2.487193,
|
||||
"emos_mean_crps": 2.342342,
|
||||
"emos_mean_crps": 2.021264,
|
||||
"legacy_mean_mae": 3.205,
|
||||
"emos_mean_mae": 3.205,
|
||||
"emos_mean_mae": 2.47462,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"madrid": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 6.27726,
|
||||
"emos_mean_crps": 5.967277,
|
||||
"emos_mean_crps": 6.588692,
|
||||
"legacy_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.33,
|
||||
"emos_mean_mae": 7.486713,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"miami": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 28.637631,
|
||||
"emos_mean_crps": 28.482516,
|
||||
"emos_mean_crps": 28.800796,
|
||||
"legacy_mean_mae": 30.175,
|
||||
"emos_mean_mae": 30.175,
|
||||
"emos_mean_mae": 30.107593,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"milan": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 4.401392,
|
||||
"emos_mean_crps": 3.858031,
|
||||
"emos_mean_crps": 4.947416,
|
||||
"legacy_mean_mae": 4.06,
|
||||
"emos_mean_mae": 4.06,
|
||||
"emos_mean_mae": 5.6958,
|
||||
"legacy_bucket_hit_rate": 0.666667,
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
},
|
||||
"munich": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 3.145192,
|
||||
"emos_mean_crps": 3.011312,
|
||||
"emos_mean_crps": 3.200946,
|
||||
"legacy_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.64,
|
||||
"emos_mean_mae": 3.634475,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"new york": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 3.692845,
|
||||
"emos_mean_crps": 3.407357,
|
||||
"emos_mean_crps": 3.881457,
|
||||
"legacy_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.94,
|
||||
"emos_mean_mae": 4.954702,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"paris": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 4.013782,
|
||||
"emos_mean_crps": 3.979293,
|
||||
"emos_mean_crps": 4.130614,
|
||||
"legacy_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.265,
|
||||
"emos_mean_mae": 4.314216,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"sao paulo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.540967,
|
||||
"emos_mean_crps": 5.272063,
|
||||
"emos_mean_crps": 5.483258,
|
||||
"legacy_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.57,
|
||||
"emos_mean_mae": 6.358434,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"seattle": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.315488,
|
||||
"emos_mean_crps": 0.425909,
|
||||
"emos_mean_crps": 0.271998,
|
||||
"legacy_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.0,
|
||||
"emos_mean_mae": 0.105037,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
},
|
||||
"seoul": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.313754,
|
||||
"emos_mean_crps": 0.412831,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.508331,
|
||||
"emos_mean_crps": 1.718042,
|
||||
"legacy_mean_mae": 0.2,
|
||||
"emos_mean_mae": 2.57121,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"shanghai": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.299116,
|
||||
"emos_mean_crps": 0.394855,
|
||||
"emos_mean_crps": 0.553444,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.71746,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.666667
|
||||
},
|
||||
"shenzhen": {
|
||||
"samples": 1,
|
||||
"legacy_mean_crps": 0.798351,
|
||||
"emos_mean_crps": 0.762787,
|
||||
"legacy_mean_mae": 0.9,
|
||||
"emos_mean_mae": 0.9,
|
||||
"legacy_mean_crps": 1.198351,
|
||||
"emos_mean_crps": 0.507942,
|
||||
"legacy_mean_mae": 1.3,
|
||||
"emos_mean_mae": 0.644561,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"singapore": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.281993,
|
||||
"emos_mean_crps": 0.37264,
|
||||
"emos_mean_crps": 0.877023,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"emos_mean_mae": 1.142765,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"taipei": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 0.356996,
|
||||
"emos_mean_crps": 0.472738,
|
||||
"legacy_mean_mae": 0.1,
|
||||
"emos_mean_mae": 0.1,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.950739,
|
||||
"emos_mean_crps": 1.257972,
|
||||
"legacy_mean_mae": 0.94,
|
||||
"emos_mean_mae": 1.687014,
|
||||
"legacy_bucket_hit_rate": 0.6,
|
||||
"emos_bucket_hit_rate": 0.2
|
||||
},
|
||||
"tel aviv": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.446758,
|
||||
"emos_mean_crps": 0.578006,
|
||||
"emos_mean_crps": 0.482341,
|
||||
"legacy_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.3,
|
||||
"emos_mean_mae": 0.62659,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
},
|
||||
"tokyo": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.450128,
|
||||
"emos_mean_crps": 0.582151,
|
||||
"legacy_mean_mae": 0.25,
|
||||
"emos_mean_mae": 0.25,
|
||||
"legacy_bucket_hit_rate": 0.5,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"samples": 5,
|
||||
"legacy_mean_crps": 0.879366,
|
||||
"emos_mean_crps": 0.989336,
|
||||
"legacy_mean_mae": 1.022,
|
||||
"emos_mean_mae": 1.450474,
|
||||
"legacy_bucket_hit_rate": 0.2,
|
||||
"emos_bucket_hit_rate": 0.2
|
||||
},
|
||||
"toronto": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 5.497916,
|
||||
"emos_mean_crps": 5.240552,
|
||||
"emos_mean_crps": 6.296632,
|
||||
"legacy_mean_mae": 6.33,
|
||||
"emos_mean_mae": 6.33,
|
||||
"emos_mean_mae": 7.003984,
|
||||
"legacy_bucket_hit_rate": 0.0,
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"warsaw": {
|
||||
"samples": 3,
|
||||
"legacy_mean_crps": 1.618875,
|
||||
"emos_mean_crps": 1.553232,
|
||||
"emos_mean_crps": 1.649515,
|
||||
"legacy_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.056667,
|
||||
"emos_mean_mae": 2.073494,
|
||||
"legacy_bucket_hit_rate": 0.333333,
|
||||
"emos_bucket_hit_rate": 0.333333
|
||||
"emos_bucket_hit_rate": 0.0
|
||||
},
|
||||
"wellington": {
|
||||
"samples": 2,
|
||||
"legacy_mean_crps": 0.364919,
|
||||
"emos_mean_crps": 0.475875,
|
||||
"emos_mean_crps": 0.939463,
|
||||
"legacy_mean_mae": 0.15,
|
||||
"emos_mean_mae": 0.15,
|
||||
"emos_mean_mae": 1.007263,
|
||||
"legacy_bucket_hit_rate": 1.0,
|
||||
"emos_bucket_hit_rate": 1.0
|
||||
"emos_bucket_hit_rate": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,9 @@ export async function GET(req: NextRequest) {
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const fetchOptions = {
|
||||
headers: auth.headers,
|
||||
next: { revalidate: 300 },
|
||||
} as const;
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
...fetchOptions,
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
@@ -40,7 +37,7 @@ export async function GET(req: NextRequest) {
|
||||
const response = buildCachedJsonResponse(
|
||||
req,
|
||||
data,
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
|
||||
"no-store, max-age=0",
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
|
||||
+169
-50
@@ -2,13 +2,101 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
PolyWeather — Unified Design Token System
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Background Scale ── */
|
||||
--color-bg-base: #080c14;
|
||||
--color-bg-raised: #0d1321;
|
||||
--color-bg-overlay: #111827;
|
||||
--color-bg-card: rgba(15, 23, 42, 0.72);
|
||||
--color-bg-input: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-disabled: #475569;
|
||||
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #00e5ff;
|
||||
--color-accent-secondary: #6366f1;
|
||||
--color-accent-tertiary: #a78bfa;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
--color-signal-success: #22c55e;
|
||||
--color-signal-warning: #f59e0b;
|
||||
--color-signal-danger: #ef4444;
|
||||
--color-signal-info: #3b82f6;
|
||||
|
||||
/* ── Risk Colors (aliased from signal) ── */
|
||||
--color-risk-high: var(--color-signal-danger);
|
||||
--color-risk-medium: var(--color-signal-warning);
|
||||
--color-risk-low: var(--color-signal-success);
|
||||
|
||||
/* ── Border ── */
|
||||
--color-border-default: rgba(99, 102, 241, 0.12);
|
||||
--color-border-hover: rgba(99, 102, 241, 0.28);
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* ── Shadow / Elevation ── */
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25);
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-data:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Geist", "Inter", -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
|
||||
/* ── Spacing (4px grid) ── */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Glass / Blur ── */
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.72;
|
||||
--glass-opacity-2: 0.85;
|
||||
--glass-opacity-3: 0.92;
|
||||
|
||||
/* ── Layout ── */
|
||||
--header-height: 52px;
|
||||
--sidebar-width: 260px;
|
||||
--panel-width: 560px;
|
||||
|
||||
/* ── Motion ── */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 190 95% 56%;
|
||||
--primary: 187 100% 50%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
@@ -22,28 +110,32 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 10% -10%,
|
||||
rgba(34, 211, 238, 0.2),
|
||||
rgba(0, 229, 255, 0.12),
|
||||
transparent 40%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 0%,
|
||||
rgba(59, 130, 246, 0.14),
|
||||
rgba(99, 102, 241, 0.1),
|
||||
transparent 36%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 100%,
|
||||
rgba(8, 47, 73, 0.5),
|
||||
rgba(8, 47, 73, 0.4),
|
||||
transparent 48%
|
||||
),
|
||||
hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Map Components (Leaflet overrides + map markers)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer components {
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
@@ -52,38 +144,41 @@
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: rgba(2, 6, 23, 0.82) !important;
|
||||
border-color: rgba(71, 85, 105, 0.5) !important;
|
||||
color: #e2e8f0 !important;
|
||||
background: rgba(8, 12, 20, 0.85) !important;
|
||||
border-color: var(--color-border-default) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
background: rgba(13, 19, 33, 0.95) !important;
|
||||
border-color: var(--color-border-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Map Pill (temperature labels on map) ── */
|
||||
.map-pill {
|
||||
min-width: 52px;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
color: #f8fafc;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
box-shadow:
|
||||
0 2px 14px rgba(2, 6, 23, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.map-pill.high {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
background: linear-gradient(135deg, var(--color-signal-danger), #b91c1c);
|
||||
}
|
||||
|
||||
.map-pill.medium {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
background: linear-gradient(135deg, var(--color-signal-warning), #b45309);
|
||||
}
|
||||
|
||||
.map-pill.low {
|
||||
@@ -93,21 +188,23 @@
|
||||
.map-pill.active {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(34, 211, 238, 0.4),
|
||||
0 8px 24px rgba(0, 229, 255, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ── Glass utility ── */
|
||||
.glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 23, 42, 0.9) 0%,
|
||||
rgba(2, 6, 23, 0.75) 100%
|
||||
rgba(13, 19, 33, 0.88) 0%,
|
||||
rgba(8, 12, 20, 0.75) 100%
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
}
|
||||
|
||||
/* ── Fade-up entrance ── */
|
||||
.fade-up {
|
||||
animation: fadeUp 450ms ease-out;
|
||||
animation: fadeUp 400ms var(--transition-slow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,47 +221,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Map Marker Components (nearby stations, city bubbles)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer components {
|
||||
.nearby-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
gap: var(--space-2);
|
||||
background: rgba(13, 19, 33, 0.9);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-elevation-2);
|
||||
backdrop-filter: var(--glass-blur-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nearby-name {
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nearby-temp {
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nearby-unit {
|
||||
color: rgba(148, 163, 184, 0.85);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.wind-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(34, 211, 238, 0.9);
|
||||
gap: var(--space-1);
|
||||
margin-left: var(--space-1);
|
||||
padding-left: var(--space-2);
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-accent-primary);
|
||||
font-size: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.wind-arrow {
|
||||
@@ -172,6 +274,7 @@
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* ── City Marker (bubble on map) ── */
|
||||
.city-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -184,13 +287,14 @@
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
color: white;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-elevation-2);
|
||||
}
|
||||
|
||||
.marker-bubble::after {
|
||||
@@ -207,48 +311,63 @@
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high {
|
||||
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||
background: linear-gradient(135deg, #dc2626, var(--color-signal-danger));
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
|
||||
.marker-bubble.risk-high::after {
|
||||
border-top-color: var(--color-signal-danger);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium {
|
||||
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||
background: linear-gradient(135deg, #d97706, var(--color-signal-warning));
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
|
||||
.marker-bubble.risk-medium::after {
|
||||
border-top-color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.marker-bubble.risk-low::after { border-top-color: #10b981; }
|
||||
.marker-bubble.risk-low::after {
|
||||
border-top-color: #10b981;
|
||||
}
|
||||
|
||||
.marker-name {
|
||||
margin-top: 8px;
|
||||
margin-top: var(--space-2);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.city-marker.selected .marker-bubble {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(51, 65, 85, 0.75);
|
||||
border-radius: 9999px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes markerGlow {
|
||||
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
|
||||
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export const metadata: Metadata = {
|
||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
apple: [
|
||||
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
};
|
||||
@@ -24,9 +26,13 @@ export default function RootLayout({
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
|
||||
@@ -71,8 +72,7 @@ export function CitySidebar() {
|
||||
const aSamples = Number(a.deb_recent_sample_count ?? 0);
|
||||
const bSamples = Number(b.deb_recent_sample_count ?? 0);
|
||||
return (
|
||||
(riskOrder[aGroup] ?? 3) -
|
||||
(riskOrder[bGroup] ?? 3) ||
|
||||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
|
||||
bHitRate - aHitRate ||
|
||||
bSamples - aSamples ||
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
@@ -126,8 +126,7 @@ export function CitySidebar() {
|
||||
|
||||
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
|
||||
if (!monitor?.available) return "";
|
||||
const label =
|
||||
locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
||||
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
||||
const trendLabel =
|
||||
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
|
||||
if (!label) return "";
|
||||
@@ -170,10 +169,20 @@ export function CitySidebar() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="city-group-title">{group.label}</span>
|
||||
<span className="city-group-title">
|
||||
<span
|
||||
className={clsx("city-group-indicator", group.key)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="city-group-meta">
|
||||
<span className="city-group-count">{citiesInGroup.length}</span>
|
||||
<span className={clsx("city-group-arrow", expanded && "expanded")}>
|
||||
<span className="city-group-count">
|
||||
{citiesInGroup.length}
|
||||
</span>
|
||||
<span
|
||||
className={clsx("city-group-arrow", expanded && "expanded")}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</span>
|
||||
@@ -203,7 +212,9 @@ export function CitySidebar() {
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: detail?.current?.max_temp_time
|
||||
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
|
||||
? t("sidebar.peakAt", {
|
||||
time: detail.current.max_temp_time,
|
||||
})
|
||||
: "";
|
||||
const deviationDirection =
|
||||
snapshot?.deviation_monitor?.direction || "normal";
|
||||
@@ -225,7 +236,9 @@ export function CitySidebar() {
|
||||
>
|
||||
<div className="city-item-main">
|
||||
<span className={clsx("risk-dot", performanceTier)} />
|
||||
<span className="city-name-text">{city.display_name}</span>
|
||||
<span className="city-name-text">
|
||||
{city.display_name}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"city-temp",
|
||||
@@ -238,7 +251,18 @@ export function CitySidebar() {
|
||||
|
||||
<div className="city-item-info">
|
||||
<span className="city-local-time">
|
||||
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
|
||||
{snapshot?.local_time ? (
|
||||
<>
|
||||
<Clock
|
||||
size={10}
|
||||
strokeWidth={2}
|
||||
className="city-clock-icon"
|
||||
/>
|
||||
{snapshot.local_time}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -3,44 +3,44 @@
|
||||
────────────────────────────────────────────────────────── */
|
||||
|
||||
.root {
|
||||
/* Core palette */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: rgba(17, 24, 39, 0.85);
|
||||
--bg-glass: rgba(15, 23, 42, 0.75);
|
||||
--border-glass: rgba(99, 102, 241, 0.15);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
/* ── Palette (bridged from globals.css unified tokens) ── */
|
||||
--bg-primary: var(--color-bg-base);
|
||||
--bg-secondary: var(--color-bg-raised);
|
||||
--bg-card: var(--color-bg-card);
|
||||
--bg-glass: rgba(13, 19, 33, 0.75);
|
||||
--border-glass: var(--color-border-default);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-muted: var(--color-text-muted);
|
||||
|
||||
/* Accents */
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-blue: #6366f1;
|
||||
/* Accents — upgraded primary cyan to match new token */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--accent-green: #34d399;
|
||||
--accent-orange: #fb923c;
|
||||
--accent-red: #f87171;
|
||||
--accent-yellow: #fbbf24;
|
||||
--accent-purple: #a78bfa;
|
||||
--accent-purple: var(--color-accent-tertiary);
|
||||
|
||||
/* Risk colors */
|
||||
--risk-high: #ef4444;
|
||||
--risk-medium: #f59e0b;
|
||||
--risk-low: #22c55e;
|
||||
--risk-high: var(--color-risk-high);
|
||||
--risk-medium: var(--color-risk-medium);
|
||||
--risk-low: var(--color-risk-low);
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing — aligned with global layout tokens */
|
||||
--panel-width: 560px;
|
||||
--header-height: 56px;
|
||||
--header-height: 52px;
|
||||
--sidebar-width: 260px;
|
||||
|
||||
/* Effects */
|
||||
/* Effects — using global elevation tokens */
|
||||
--glass-blur: 20px;
|
||||
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-cyan: 0 0 20px rgba(34, 211, 238, 0.3);
|
||||
--shadow-glow-blue: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow-lg: var(--shadow-elevation-3);
|
||||
--shadow-glow-cyan: var(--shadow-glow-accent);
|
||||
--shadow-glow-blue: var(--shadow-glow-secondary);
|
||||
--transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
@@ -174,44 +174,46 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
padding: 0 20px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 14, 26, 0.95) 0%,
|
||||
rgba(10, 14, 26, 0.7) 100%
|
||||
rgba(8, 12, 20, 0.96) 0%,
|
||||
rgba(8, 12, 20, 0.82) 100%
|
||||
);
|
||||
backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.brand h1) {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-blue) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
@@ -244,27 +246,28 @@
|
||||
|
||||
.root :global(.lang-btn.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.26);
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.22);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.pulse-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
@@ -283,23 +286,22 @@
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: var(--bg-glass);
|
||||
background: rgba(13, 19, 33, 0.6);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.root :global(.refresh-btn:hover) {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.root :global(.refresh-btn.spinning) {
|
||||
animation: spin 1s linear infinite;
|
||||
@@ -335,20 +337,23 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.root :global(.city-count) {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--accent-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
@@ -382,20 +387,52 @@
|
||||
.root :global(.city-group-header) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header:hover) {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.city-group-indicator) {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-group-indicator.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.other) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.city-group-meta) {
|
||||
@@ -457,8 +494,9 @@
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
.root :global(.city-item.active) {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: var(--accent-blue);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
box-shadow: 0 0 16px rgba(0, 229, 255, 0.06), inset 0 0 0 1px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
@@ -531,13 +569,21 @@
|
||||
box-shadow: 0 0 6px var(--risk-low);
|
||||
}
|
||||
|
||||
.root :global(.city-clock-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: -1px;
|
||||
margin-right: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-temp) {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.root :global(.city-item .city-temp.loaded) {
|
||||
opacity: 1;
|
||||
@@ -1319,13 +1365,12 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1340,12 +1385,11 @@
|
||||
min-width: 240px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
border: 1px solid rgba(92, 142, 255, 0.16);
|
||||
box-shadow:
|
||||
0 24px 90px rgba(0, 0, 0, 0.42),
|
||||
@@ -1365,7 +1409,11 @@
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 50%, rgba(186, 230, 253, 0.22), transparent 54%),
|
||||
radial-gradient(
|
||||
circle at 30% 50%,
|
||||
rgba(186, 230, 253, 0.22),
|
||||
transparent 54%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.28) 0%,
|
||||
@@ -1463,14 +1511,13 @@
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
|
||||
0 0 40px rgba(34, 211, 238, 0.08);
|
||||
@@ -2281,7 +2328,8 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
.root
|
||||
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -2390,12 +2438,11 @@
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.16);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.72) 0%,
|
||||
rgba(8, 14, 30, 0.5) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.72) 0%,
|
||||
rgba(8, 14, 30, 0.5) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 48px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
@@ -2406,7 +2453,12 @@
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(14, 165, 233, 0.08) 0%, rgba(14, 165, 233, 0.02) 62%, transparent 100%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(14, 165, 233, 0.08) 0%,
|
||||
rgba(14, 165, 233, 0.02) 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-ring) {
|
||||
@@ -2604,16 +2656,23 @@
|
||||
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
|
||||
.root
|
||||
:global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
|
||||
.root
|
||||
:global(
|
||||
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track
|
||||
) {
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
|
||||
.root
|
||||
:global(
|
||||
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb
|
||||
) {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.72) 0%,
|
||||
@@ -2862,6 +2921,97 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-brief) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.22);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.78));
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy h3) {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-meta span) {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths div) {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(2, 6, 23, 0.5);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths span) {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths strong) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #67e8f9;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-sync-chip) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -2962,21 +3112,19 @@
|
||||
}
|
||||
|
||||
.root :global(.future-v2-focus-card) {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(34, 211, 238, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(34, 211, 238, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-support-card) {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-hero-card) {
|
||||
@@ -3075,15 +3223,14 @@
|
||||
position: relative;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(15, 23, 42, 0.85) 0%,
|
||||
rgba(56, 189, 248, 0.18) 15%,
|
||||
rgba(250, 204, 21, 0.24) 50%,
|
||||
rgba(56, 189, 248, 0.16) 85%,
|
||||
rgba(15, 23, 42, 0.85) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(15, 23, 42, 0.85) 0%,
|
||||
rgba(56, 189, 248, 0.18) 15%,
|
||||
rgba(250, 204, 21, 0.24) 50%,
|
||||
rgba(56, 189, 248, 0.16) 85%,
|
||||
rgba(15, 23, 42, 0.85) 100%
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -3309,14 +3456,13 @@
|
||||
margin-top: 12px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.06) 46%,
|
||||
rgba(255, 255, 255, 0.06) 54%,
|
||||
rgba(251, 191, 36, 0.12) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.06) 46%,
|
||||
rgba(255, 255, 255, 0.06) 54%,
|
||||
rgba(251, 191, 36, 0.12) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-pace-meter-midline) {
|
||||
@@ -3356,13 +3502,12 @@
|
||||
margin-bottom: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 18%,
|
||||
rgba(34, 211, 238, 0.14) 0%,
|
||||
rgba(255, 255, 255, 0.02) 48%,
|
||||
rgba(255, 255, 255, 0.015) 100%
|
||||
);
|
||||
background: radial-gradient(
|
||||
circle at 50% 18%,
|
||||
rgba(34, 211, 238, 0.14) 0%,
|
||||
rgba(255, 255, 255, 0.02) 48%,
|
||||
rgba(255, 255, 255, 0.015) 100%
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -3634,6 +3779,124 @@
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-chart-thresholds) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-chart-thresholds span) {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 47, 73, 0.24);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-list) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row) {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-left-width: 3px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.cyan) {
|
||||
border-left-color: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.amber) {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row.blue) {
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head strong) {
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-head span) {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-evidence-row p) {
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list li) {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-list li::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.65em;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-model-note) {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.future-modal-section) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -3717,14 +3980,13 @@
|
||||
}
|
||||
|
||||
.root :global(.future-trend-meter.center) {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(56, 189, 248, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 48%,
|
||||
rgba(255, 255, 255, 0.05) 52%,
|
||||
rgba(245, 158, 11, 0.1) 100%
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(56, 189, 248, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 48%,
|
||||
rgba(255, 255, 255, 0.05) 52%,
|
||||
rgba(245, 158, 11, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.future-trend-meter-midline) {
|
||||
@@ -3856,6 +4118,15 @@
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.root :global(.future-v2-meteorology-brief),
|
||||
.root :global(.future-v2-meteorology-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.future-v2-layout) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -3881,6 +4152,18 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-brief) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-paths) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-meteorology-copy h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.future-v2-card),
|
||||
.root :global(.future-modal-section) {
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||||
@@ -151,19 +150,11 @@ export function DetailPanel() {
|
||||
);
|
||||
const isPro = store.proAccess.subscriptionActive;
|
||||
const isAuthenticated = store.proAccess.authenticated;
|
||||
const isProStateLoading = store.proAccess.loading;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||
const isOverlayOpen =
|
||||
Boolean(store.futureModalDate) ||
|
||||
store.historyState.isOpen;
|
||||
const isVisible =
|
||||
store.isPanelOpen &&
|
||||
Boolean(store.selectedCity) &&
|
||||
!isOverlayOpen;
|
||||
const hasBasicPanelContent = Boolean(
|
||||
detail || selectedSummary || selectedCityItem,
|
||||
);
|
||||
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
|
||||
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
||||
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
|
||||
const panelDisplayName =
|
||||
detail?.display_name ||
|
||||
selectedSummary?.display_name ||
|
||||
@@ -187,10 +178,6 @@ export function DetailPanel() {
|
||||
() => getTodayPolymarketUrl(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const scenery = getCityScenery(detail?.name);
|
||||
const basicCurrentTemp = selectedSummary?.current?.temp;
|
||||
const basicObsTime = selectedSummary?.current?.obs_time;
|
||||
const basicDeb = selectedSummary?.deb?.prediction;
|
||||
const basicSettlementLabel =
|
||||
selectedSummary?.current?.settlement_source_label ||
|
||||
selectedCityItem?.settlement_source_label ||
|
||||
@@ -200,11 +187,10 @@ export function DetailPanel() {
|
||||
selectedCityItem?.airport ||
|
||||
selectedSummary?.icao ||
|
||||
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
||||
const isBasicSummaryLoading =
|
||||
!detail && !selectedSummary && store.loadingState.cityDetail;
|
||||
const shouldShowSyncCard =
|
||||
!detail &&
|
||||
(store.loadingState.cityDetail || isProStateLoading || isAuthenticated);
|
||||
const heroSettlementLabel =
|
||||
detail?.current?.settlement_source_label || basicSettlementLabel;
|
||||
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
||||
|
||||
const blurActiveElement = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
const active = document.activeElement;
|
||||
@@ -212,6 +198,7 @@ export function DetailPanel() {
|
||||
active.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeatureAccess = (feature: "today" | "history") => {
|
||||
blurActiveElement();
|
||||
|
||||
@@ -251,10 +238,7 @@ export function DetailPanel() {
|
||||
|
||||
if (!isVisible) {
|
||||
panel.setAttribute("inert", "");
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
panel.contains(document.activeElement)
|
||||
) {
|
||||
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur();
|
||||
@@ -303,10 +287,7 @@ export function DetailPanel() {
|
||||
}, [detail, isVisible]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={clsx("detail-panel", isVisible && "visible")}
|
||||
>
|
||||
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
|
||||
<div className="panel-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -319,8 +300,15 @@ export function DetailPanel() {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="panel-title-area">
|
||||
<h2>{panelDisplayName.toUpperCase()}</h2>
|
||||
<div className="panel-title-area">
|
||||
<div className="panel-title-stack">
|
||||
<div className="panel-overline">
|
||||
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
||||
<span className="panel-overline-sep">•</span>
|
||||
<span>{panelDisplayName.toUpperCase()}</span>
|
||||
</div>
|
||||
<h2>{panelDisplayName}</h2>
|
||||
</div>
|
||||
{store.loadingState.cityDetail && (
|
||||
<div className="panel-loading-hint" role="status" aria-live="polite">
|
||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
||||
@@ -335,202 +323,111 @@ export function DetailPanel() {
|
||||
<span className={clsx("risk-badge", panelRiskLevel)}>
|
||||
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
||||
</span>
|
||||
<div className="relative group">
|
||||
{marketUrl ? (
|
||||
<a
|
||||
className="history-btn"
|
||||
href={marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={
|
||||
locale === "en-US"
|
||||
? "Open today's Polymarket market"
|
||||
: "打开今日 Polymarket 题目页"
|
||||
}
|
||||
>
|
||||
{locale === "en-US" ? "Open Market" : "打开 Polymarket"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
|
||||
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{marketUrl ? (
|
||||
<a
|
||||
className="panel-action-button panel-action-button-ghost"
|
||||
href={marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={
|
||||
isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} (Pro)`
|
||||
locale === "en-US"
|
||||
? "Open today's Polymarket market"
|
||||
: "打开今日 Polymarket 题目页"
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro
|
||||
? t("detail.todayAnalysis")
|
||||
: `${t("detail.todayAnalysis")} · Pro`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("history-btn", !isPro && "pro-locked")}
|
||||
title={
|
||||
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
</button>
|
||||
</div>
|
||||
{locale === "en-US" ? "Market" : "市场页"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"panel-action-button",
|
||||
"panel-action-button-primary",
|
||||
!isPro && "pro-locked",
|
||||
)}
|
||||
title={
|
||||
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
|
||||
}
|
||||
onClick={() => handleFeatureAccess("today")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"panel-action-button",
|
||||
"panel-action-button-secondary",
|
||||
!isPro && "pro-locked",
|
||||
)}
|
||||
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
|
||||
onClick={() => handleFeatureAccess("history")}
|
||||
disabled={!store.selectedCity}
|
||||
>
|
||||
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
{!hasBasicPanelContent ? (
|
||||
<section>
|
||||
<section className="detail-summary-shell detail-empty-state">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||
{store.loadingState.cityDetail
|
||||
? t("detail.loading")
|
||||
: t("detail.emptyHint")}
|
||||
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
|
||||
</div>
|
||||
</section>
|
||||
) : !detail ? (
|
||||
<>
|
||||
<section className="detail-section">
|
||||
<h3>{locale === "en-US" ? "City Snapshot" : "城市概览"}</h3>
|
||||
{isBasicSummaryLoading ? (
|
||||
<div className="detail-mini-meta">
|
||||
{locale === "en-US"
|
||||
? "Syncing public city snapshot..."
|
||||
: "正在同步城市基础信息..."}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="detail-grid">
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Current temp" : "当前温度"}
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{basicCurrentTemp != null
|
||||
? `${basicCurrentTemp}${selectedSummary?.temp_symbol || ""}`
|
||||
: locale === "en-US"
|
||||
? "--"
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Observed" : "观测时间"}
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{basicObsTime || (locale === "en-US" ? "Pending" : "待更新")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">DEB</span>
|
||||
<span className="detail-value">
|
||||
{basicDeb != null
|
||||
? `${basicDeb}${selectedSummary?.temp_symbol || ""}`
|
||||
: locale === "en-US"
|
||||
? isBasicSummaryLoading
|
||||
? "Syncing..."
|
||||
: "Pending"
|
||||
: isBasicSummaryLoading
|
||||
? "同步中..."
|
||||
: "待更新"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Settlement" : "结算口径"}
|
||||
</span>
|
||||
<span className="detail-value">{basicSettlementLabel}</span>
|
||||
</div>
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{locale === "en-US" ? "Airport" : "结算机场"}
|
||||
</span>
|
||||
<span className="detail-value">{basicAirportLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<div className="detail-card">
|
||||
<span className="detail-label">
|
||||
{shouldShowSyncCard
|
||||
? locale === "en-US"
|
||||
? "Detail sync"
|
||||
: "详情同步"
|
||||
: locale === "en-US"
|
||||
? "Pro features"
|
||||
: "Pro 功能"}
|
||||
</span>
|
||||
<span className="detail-value" style={{ fontSize: "15px" }}>
|
||||
{shouldShowSyncCard
|
||||
? locale === "en-US"
|
||||
? "Full city detail is still syncing. The deeper panel will appear automatically."
|
||||
: "完整城市详情仍在同步中,深度面板会自动补齐。"
|
||||
: locale === "en-US"
|
||||
? "Intraday analysis, history reconciliation, and deeper structure signals require Pro."
|
||||
: "今日日内分析、历史对账和更深入的结构信号需要 Pro。"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
||||
{store.loadingState.cityDetail
|
||||
? locale === "en-US"
|
||||
? "Loading city cards..."
|
||||
: "正在加载城市卡片..."
|
||||
: locale === "en-US"
|
||||
? "City cards will appear here."
|
||||
: "城市卡片会显示在这里。"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="detail-scenery-card">
|
||||
{scenery ? (
|
||||
<>
|
||||
<img
|
||||
className="detail-scenery-image"
|
||||
src={scenery.imageUrl}
|
||||
alt={t("detail.sceneryAlt", { city: detail?.display_name || "" })}
|
||||
/>
|
||||
<div className="detail-scenery-overlay">
|
||||
<div className="detail-scenery-copy">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="detail-scenery-credit"
|
||||
href={scenery.creditUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{scenery.creditLabel}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-scenery-fallback">
|
||||
<span className="detail-scenery-kicker">
|
||||
{detail?.display_name}
|
||||
</span>
|
||||
<strong className="detail-scenery-title">
|
||||
{t("detail.sceneryTitle")}
|
||||
</strong>
|
||||
<span className="detail-scenery-subtitle">
|
||||
{t("detail.sceneryFallback")}
|
||||
</span>
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
|
||||
<h3>{t("detail.profile")}</h3>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>{t("detail.profile")}</h3>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
{profileStats.map((item) => (
|
||||
<div key={item.label} className="detail-card">
|
||||
<article key={item.label} className="detail-card">
|
||||
<span className="detail-label">{item.label}</span>
|
||||
<span className="detail-value">{item.value}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{officialLinks.length > 0 ? (
|
||||
<section className="detail-section">
|
||||
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
|
||||
<section className="detail-structured-section">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Primary references" : "官方参考"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="detail-source-note">
|
||||
{locale === "en-US"
|
||||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
||||
@@ -545,9 +442,7 @@ export function DetailPanel() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="detail-source-kind">
|
||||
{link.kind.toUpperCase()}
|
||||
</span>
|
||||
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
|
||||
<span className="detail-source-label">{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -555,10 +450,17 @@ export function DetailPanel() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="detail-section rounded-2xl">
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
<section className="detail-structured-section rounded-2xl">
|
||||
<div className="detail-section-head">
|
||||
<div>
|
||||
<div className="detail-section-kicker">
|
||||
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
||||
</div>
|
||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{heavyContentReady ? (
|
||||
<DetailMiniTemperatureChart detail={detail!} />
|
||||
<DetailMiniTemperatureChart detail={detail} />
|
||||
) : (
|
||||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
.root :global(.panel-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.panel-overline-sep) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.root :global(.panel-subtitle) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.panel-weather-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-strong) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border-color: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.panel-meta-chip-muted) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.panel-actions) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button:hover) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-primary) {
|
||||
color: #05111f;
|
||||
background: linear-gradient(135deg, #67e8f9, #38bdf8);
|
||||
border-color: rgba(103, 232, 249, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-secondary) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.root :global(.panel-action-button-ghost) {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell) {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.panel-body section.detail-summary-shell) {
|
||||
padding: 16px;
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-shell-live) {
|
||||
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
|
||||
}
|
||||
|
||||
.root :global(.detail-section-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-section-kicker) {
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-main) {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-hero) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-temp) {
|
||||
color: var(--text-primary);
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-supporting) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.detail-summary-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.detail-card) {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.detail-card-hero) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.detail-card-wide) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.root :global(.detail-callout-card) {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.root :global(.detail-value-muted) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.detail-structured-section) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
padding: 16px 16px 18px;
|
||||
}
|
||||
|
||||
.root :global(.detail-empty-state) {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getTemperatureChartData,
|
||||
getWeatherSummary,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
|
||||
|
||||
function normalizeMarketValue(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
@@ -175,6 +176,61 @@ function parsePercentFromText(value?: string | number | null) {
|
||||
return parseLeadingNumber(text);
|
||||
}
|
||||
|
||||
function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "high") return locale === "en-US" ? "High" : "高";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
|
||||
return locale === "en-US" ? "Pending" : "待确认";
|
||||
}
|
||||
|
||||
function formatSignalDirection(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "support") return locale === "en-US" ? "Support" : "支持升温";
|
||||
if (normalized === "suppress") return locale === "en-US" ? "Suppress" : "压制峰值";
|
||||
return locale === "en-US" ? "Neutral" : "中性";
|
||||
}
|
||||
|
||||
function formatSignalStrength(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
return locale === "en-US" ? "Weak" : "弱";
|
||||
}
|
||||
|
||||
function signalTone(signal?: IntradayMeteorologySignal | null) {
|
||||
const direction = String(signal?.direction || "").trim().toLowerCase();
|
||||
if (direction === "support") return "cyan";
|
||||
if (direction === "suppress") return "amber";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function localizedText(
|
||||
locale: string,
|
||||
primary?: string | null,
|
||||
english?: string | null,
|
||||
) {
|
||||
const en = String(english || "").trim();
|
||||
const value = String(primary || "").trim();
|
||||
if (locale === "en-US" && en) return en;
|
||||
return value || en;
|
||||
}
|
||||
|
||||
function localizedList(
|
||||
locale: string,
|
||||
primary?: string[] | null,
|
||||
english?: string[] | null,
|
||||
) {
|
||||
const en = Array.isArray(english)
|
||||
? english.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
const value = Array.isArray(primary)
|
||||
? primary.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
if (locale === "en-US" && en.length) return en;
|
||||
return value.length ? value : en;
|
||||
}
|
||||
|
||||
function getTrendMetricVisual(metric: {
|
||||
label?: string;
|
||||
value?: string;
|
||||
@@ -1081,6 +1137,66 @@ export function FutureForecastModal() {
|
||||
}
|
||||
return lines.slice(0, 3);
|
||||
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
|
||||
const intradayMeteorology = detail.intraday_meteorology || {};
|
||||
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
|
||||
? intradayMeteorology.signal_contributions
|
||||
: [];
|
||||
const invalidationRules = localizedList(
|
||||
locale,
|
||||
intradayMeteorology.invalidation_rules,
|
||||
intradayMeteorology.invalidation_rules_en,
|
||||
);
|
||||
const confirmationRules = localizedList(
|
||||
locale,
|
||||
intradayMeteorology.confirmation_rules,
|
||||
intradayMeteorology.confirmation_rules_en,
|
||||
);
|
||||
const meteorologyHeadline =
|
||||
localizedText(
|
||||
locale,
|
||||
intradayMeteorology.headline,
|
||||
intradayMeteorology.headline_en,
|
||||
) ||
|
||||
todayTradeSummaryLines[0] ||
|
||||
(locale === "en-US"
|
||||
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
|
||||
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
|
||||
const baseCaseBucket =
|
||||
String(intradayMeteorology.base_case_bucket || "").trim() ||
|
||||
formatBucketLabel(topProbabilityBucket);
|
||||
const nextObservationTime =
|
||||
String(intradayMeteorology.next_observation_time || "").trim() || "--";
|
||||
const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
|
||||
const referenceObservedTemp =
|
||||
topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
|
||||
? Number(topObservedTemp)
|
||||
: detail.current?.temp != null
|
||||
? Number(detail.current.temp)
|
||||
: null;
|
||||
const gapToBaseBucket =
|
||||
baseBucketNumber != null && referenceObservedTemp != null
|
||||
? Math.max(0, baseBucketNumber - referenceObservedTemp)
|
||||
: null;
|
||||
const pathStatus =
|
||||
gapToBaseBucket == null
|
||||
? locale === "en-US"
|
||||
? "Awaiting anchor"
|
||||
: "等待锚点"
|
||||
: gapToBaseBucket <= 0.05
|
||||
? locale === "en-US"
|
||||
? "Base path touched"
|
||||
: "基准路径已触达"
|
||||
: gapToBaseBucket <= 1.0
|
||||
? locale === "en-US"
|
||||
? "Base path open"
|
||||
: "基准路径开放"
|
||||
: locale === "en-US"
|
||||
? "Needs peak push"
|
||||
: "需要峰值推动";
|
||||
const peakWindowText =
|
||||
String(intradayMeteorology.peak_window || "").trim() ||
|
||||
paceView?.peakWindowText ||
|
||||
"--";
|
||||
const syncStatusItems = [
|
||||
{
|
||||
key: "base",
|
||||
@@ -1089,8 +1205,8 @@ export function FutureForecastModal() {
|
||||
locale === "en-US" ? "Base analysis ready" : "基础分析已加载",
|
||||
note:
|
||||
locale === "en-US"
|
||||
? "Forecast curve, anchor state, and current structure are available."
|
||||
: "预测曲线、锚点状态和当前结构已经可用。",
|
||||
? "Forecast curve, anchor state, and the core intraday view are available."
|
||||
: "预测曲线、锚点状态和核心日内视图已经可用。",
|
||||
},
|
||||
{
|
||||
key: "market",
|
||||
@@ -1104,26 +1220,6 @@ export function FutureForecastModal() {
|
||||
? "Probability buckets are derived from the local model stack."
|
||||
: "概率桶当前由本地模型栈推导。",
|
||||
},
|
||||
{
|
||||
key: "structure",
|
||||
state: isStructureSyncing ? "syncing" : "ready",
|
||||
label:
|
||||
locale === "en-US"
|
||||
? isStructureSyncing
|
||||
? "Backfilling deep structure"
|
||||
: "Deep structure ready"
|
||||
: isStructureSyncing
|
||||
? "深度结构补齐中"
|
||||
: "深度结构已加载",
|
||||
note:
|
||||
locale === "en-US"
|
||||
? isStructureSyncing
|
||||
? "Upper-air, nearby network, and deeper fusion signals are still coming in."
|
||||
: "Upper-air, nearby network, and deeper fusion signals are ready."
|
||||
: isStructureSyncing
|
||||
? "高空、周边站网和更深层融合信号还在补齐。"
|
||||
: "高空、周边站网和更深层融合信号已可用。",
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
@@ -1155,58 +1251,74 @@ export function FutureForecastModal() {
|
||||
) : (
|
||||
<div className="modal-content large future-modal">
|
||||
<div className="modal-header">
|
||||
<h2
|
||||
id="future-modal-title"
|
||||
className="future-modal-title-with-actions"
|
||||
>
|
||||
<span>
|
||||
{isToday
|
||||
? t("future.todayTitle", {
|
||||
city: detail.display_name.toUpperCase(),
|
||||
})
|
||||
: t("future.dateTitle", {
|
||||
city: detail.display_name.toUpperCase(),
|
||||
date: dateStr,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className={clsx(
|
||||
"future-refresh-btn",
|
||||
isAnyLayerSyncing && "spinning",
|
||||
)}
|
||||
disabled={!isPro || isProLoading}
|
||||
onClick={() => {
|
||||
if (isToday) {
|
||||
void store.openTodayModal(true);
|
||||
return;
|
||||
}
|
||||
store.openFutureModal(dateStr, true);
|
||||
}}
|
||||
title={
|
||||
!isPro
|
||||
? locale === "en-US"
|
||||
? "Pro subscription required"
|
||||
: "需要 Pro 订阅"
|
||||
: locale === "en-US"
|
||||
? "Refresh Data"
|
||||
: "刷新数据"
|
||||
}
|
||||
<div className="modal-title-stack">
|
||||
<div className="modal-overline">
|
||||
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
|
||||
<span className="modal-overline-sep">•</span>
|
||||
<span>{detail.display_name.toUpperCase()}</span>
|
||||
</div>
|
||||
<h2
|
||||
id="future-modal-title"
|
||||
className="future-modal-title-with-actions"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<span>
|
||||
{isToday
|
||||
? t("future.todayTitle", {
|
||||
city: detail.display_name.toUpperCase(),
|
||||
})
|
||||
: t("future.dateTitle", {
|
||||
city: detail.display_name.toUpperCase(),
|
||||
date: dateStr,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className={clsx(
|
||||
"future-refresh-btn",
|
||||
isAnyLayerSyncing && "spinning",
|
||||
)}
|
||||
disabled={!isPro || isProLoading}
|
||||
onClick={() => {
|
||||
if (isToday) {
|
||||
void store.openTodayModal(true);
|
||||
return;
|
||||
}
|
||||
store.openFutureModal(dateStr, true);
|
||||
}}
|
||||
title={
|
||||
!isPro
|
||||
? locale === "en-US"
|
||||
? "Pro subscription required"
|
||||
: "需要 Pro 订阅"
|
||||
: locale === "en-US"
|
||||
? "Refresh Data"
|
||||
: "刷新数据"
|
||||
}
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<div className="modal-subtitle">
|
||||
{isToday
|
||||
? locale === "en-US"
|
||||
? "Base signal first, then probability and model layers."
|
||||
: "先看基础信号,再看概率层和模型层。"
|
||||
: locale === "en-US"
|
||||
? "Forward date view with phased model and structure sync."
|
||||
: "未来日期视图,模型层与结构层分阶段补齐。"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
@@ -1219,14 +1331,53 @@ export function FutureForecastModal() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body future-modal-body">
|
||||
<section className="future-v2-sync-strip" aria-live="polite">
|
||||
{isToday && (
|
||||
<section className="future-v2-meteorology-brief">
|
||||
<div className="future-v2-meteorology-copy">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
|
||||
</div>
|
||||
<h3>{meteorologyHeadline}</h3>
|
||||
<div className="future-v2-meteorology-meta">
|
||||
<span>
|
||||
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
|
||||
{formatConfidenceLabel(intradayMeteorology.confidence, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Peak window" : "峰值窗口"} · {peakWindowText}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Next observation" : "下一观测"} · {nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-meteorology-paths">
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Base case" : "基准路径"}</span>
|
||||
<strong>{baseCaseBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Upside" : "上修路径"}</span>
|
||||
<strong>{intradayMeteorology.upside_bucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Downside" : "下修路径"}</span>
|
||||
<strong>{intradayMeteorology.downside_bucket || "--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={clsx(
|
||||
"future-v2-sync-strip",
|
||||
isToday && "future-v2-sync-strip-compact",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{syncStatusItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={clsx(
|
||||
"future-v2-sync-chip",
|
||||
item.state === "syncing" && "syncing",
|
||||
)}
|
||||
className="future-v2-sync-chip"
|
||||
>
|
||||
<span className="future-v2-sync-dot" aria-hidden="true" />
|
||||
<div className="future-v2-sync-copy">
|
||||
@@ -1237,18 +1388,7 @@ export function FutureForecastModal() {
|
||||
))}
|
||||
</section>
|
||||
{isNoaaSettlement && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid rgba(56, 189, 248, 0.24)",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(14, 165, 233, 0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div className="modal-callout modal-callout-info">
|
||||
{locale === "en-US"
|
||||
? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
|
||||
: `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
|
||||
@@ -1332,6 +1472,22 @@ export function FutureForecastModal() {
|
||||
</span>
|
||||
<strong>{detail.current?.obs_time || "--"}</strong>
|
||||
</div>
|
||||
<div className="future-v2-mini-item">
|
||||
<span>
|
||||
{locale === "en-US" ? "Gap to base" : "距基准档"}
|
||||
</span>
|
||||
<strong>
|
||||
{gapToBaseBucket != null
|
||||
? `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="future-v2-mini-item">
|
||||
<span>
|
||||
{locale === "en-US" ? "Path state" : "路径状态"}
|
||||
</span>
|
||||
<strong>{pathStatus}</strong>
|
||||
</div>
|
||||
<div className="future-v2-mini-item">
|
||||
<span>
|
||||
{locale === "en-US" ? "Sunrise" : "日出时间"}
|
||||
@@ -1491,19 +1647,120 @@ export function FutureForecastModal() {
|
||||
|
||||
<main className="future-v2-right">
|
||||
<section className="future-modal-section future-v2-main-chart">
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Today's temperature forecast (obs + market)"
|
||||
: "今日气温预测(观测 + 市场)"}
|
||||
</h3>
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Primary view" : "主视图"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Today's temperature forecast (obs + market)"
|
||||
: "今日气温预测(观测 + 市场)"}
|
||||
</h3>
|
||||
</div>
|
||||
<DailyTemperatureChart dateStr={dateStr} />
|
||||
<div className="future-v2-chart-thresholds">
|
||||
<span>{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}</span>
|
||||
<span>{locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}</span>
|
||||
<span>{locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="future-v2-meteorology-grid">
|
||||
<section className="future-modal-section future-v2-evidence-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
|
||||
</div>
|
||||
<div className="future-v2-evidence-list">
|
||||
{meteorologySignals.length > 0 ? (
|
||||
meteorologySignals.map((signal, index) => (
|
||||
<div
|
||||
key={`${signal.label || "signal"}-${index}`}
|
||||
className={clsx(
|
||||
"future-v2-evidence-row",
|
||||
signalTone(signal),
|
||||
)}
|
||||
>
|
||||
<div className="future-v2-evidence-head">
|
||||
<strong>
|
||||
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||||
</strong>
|
||||
<span>
|
||||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||||
{formatSignalStrength(signal.strength, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{localizedText(locale, signal.summary, signal.summary_en) || "--"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="future-text-block">
|
||||
{locale === "en-US"
|
||||
? "Meteorology signals are still loading."
|
||||
: "气象信号仍在加载。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Failure modes" : "失效条件"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(invalidationRules.length > 0
|
||||
? invalidationRules
|
||||
: [
|
||||
locale === "en-US"
|
||||
? "If observations stop tracking the expected curve, wait for the next refresh."
|
||||
: "若实测不再贴近预期曲线,等待下一次刷新确认。",
|
||||
]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Confirmation" : "确认条件"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(confirmationRules.length > 0
|
||||
? confirmationRules
|
||||
: [
|
||||
locale === "en-US"
|
||||
? "Keep watching the next settlement-source observation."
|
||||
: "继续观察下一次结算源报文。",
|
||||
]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="future-v2-model-note">{modelSummary}</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>
|
||||
{locale === "en-US" ? "Current Hit Odds" : "当前命中胜率"}
|
||||
</h3>
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Auxiliary probability" : "辅助概率"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "Model & Market Reference" : "模型与市场参考"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{probabilitySummary}
|
||||
</div>
|
||||
@@ -1511,14 +1768,20 @@ export function FutureForecastModal() {
|
||||
<ProbabilityDistribution
|
||||
detail={detail}
|
||||
targetDate={dateStr}
|
||||
marketScan={detail.market_scan}
|
||||
hideTitle
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>
|
||||
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
|
||||
</h3>
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Model layer" : "模型层"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="future-text-block" style={{ marginBottom: "12px" }}>
|
||||
{modelSummary}
|
||||
</div>
|
||||
@@ -1530,175 +1793,6 @@ export function FutureForecastModal() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showDeferredTodaySections ? (
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.structureToday")}</h3>
|
||||
<div className="future-front-score">
|
||||
<div className="future-front-bar" style={barStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: "50%",
|
||||
width: "2px",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-front-meta">
|
||||
<span className="future-front-pill">
|
||||
{t("future.judgement")}: {view.front.label}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.confidence")}:{" "}
|
||||
{t(`confidence.${view.front.confidence}`)}
|
||||
</span>
|
||||
<span className="future-front-pill">
|
||||
{t("future.maxPrecip")}:{" "}
|
||||
{Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
{todayTradeSummaryLines.length > 0 ? (
|
||||
<div className="future-trend-summary">
|
||||
{todayTradeSummaryLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Surface Structure" : "近地面信号"}
|
||||
</div>
|
||||
<div className="future-trend-grid">
|
||||
{view.front.metrics.slice(0, 6).map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
)}
|
||||
>
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||
</div>
|
||||
{displayedUpperAirSummary ? (
|
||||
<div className="future-trend-summary">
|
||||
{displayedUpperAirSummary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing."
|
||||
: "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
|
||||
</div>
|
||||
)}
|
||||
{displayedUpperAirMetrics.length > 0 ? (
|
||||
<div className="future-trend-grid">
|
||||
{displayedUpperAirMetrics.map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
<div className="future-trend-label">{metric.label}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-value",
|
||||
metric.tone === "warm" && "warm",
|
||||
metric.tone === "cold" && "cold",
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
{getTrendMetricVisual(metric) ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter",
|
||||
getTrendMetricVisual(metric)?.mode === "center" &&
|
||||
"center",
|
||||
)}
|
||||
>
|
||||
{getTrendMetricVisual(metric)?.mode === "center" ? (
|
||||
<span className="future-trend-meter-midline" />
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(
|
||||
"future-trend-meter-fill",
|
||||
getTrendMetricVisual(metric)?.tone === "warm" &&
|
||||
"warm",
|
||||
getTrendMetricVisual(metric)?.tone === "cold" &&
|
||||
"cold",
|
||||
)}
|
||||
style={{
|
||||
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-trend-note">{metric.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="future-trend-card future-trend-card-empty">
|
||||
<div className="future-trend-label">
|
||||
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
|
||||
</div>
|
||||
<div className="future-trend-value">
|
||||
{locale === "en-US" ? "Not available" : "暂不可用"}
|
||||
</div>
|
||||
<div className="future-trend-note">
|
||||
{locale === "en-US"
|
||||
? "No upper-air diagnostic feed is attached to this city right now."
|
||||
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
) : (
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.structureToday")}</h3>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Surface structure, upper-air diagnostics, and trade commentary are loading after the primary chart."
|
||||
: "近地面结构、高空诊断和交易提示会在主图之后继续后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import { LogIn, UserRound } from "lucide-react";
|
||||
import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
@@ -29,12 +29,16 @@ export function HeaderBar() {
|
||||
const docsHref = "/docs/intro";
|
||||
const docsActive = pathname?.startsWith("/docs");
|
||||
const trialPromoLabel =
|
||||
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
|
||||
locale === "en-US"
|
||||
? "New users get 3-day Pro trial"
|
||||
: "新用户可免费体验 3 天 Pro";
|
||||
|
||||
const accountHref = isAuthenticated
|
||||
? "/account"
|
||||
: "/auth/login?next=%2Faccount";
|
||||
const accountLabel = isAuthenticated ? t("header.account") : t("header.signIn");
|
||||
const accountLabel = isAuthenticated
|
||||
? t("header.account")
|
||||
: t("header.signIn");
|
||||
const accountAria = isAuthenticated
|
||||
? t("header.accountAria")
|
||||
: t("header.signInAria");
|
||||
@@ -49,12 +53,10 @@ export function HeaderBar() {
|
||||
const showRenewReminder =
|
||||
isAuthenticated &&
|
||||
!store.proAccess.loading &&
|
||||
(
|
||||
(store.proAccess.subscriptionActive &&
|
||||
expiryInfo &&
|
||||
expiryInfo.daysLeft <= 3) ||
|
||||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo))
|
||||
);
|
||||
((store.proAccess.subscriptionActive &&
|
||||
expiryInfo &&
|
||||
expiryInfo.daysLeft <= 3) ||
|
||||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo)));
|
||||
const renewReminderLabel = !showRenewReminder
|
||||
? ""
|
||||
: !store.proAccess.subscriptionActive
|
||||
@@ -81,7 +83,11 @@ export function HeaderBar() {
|
||||
</div>
|
||||
|
||||
<div className="header-right">
|
||||
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
|
||||
<div
|
||||
className="lang-switch"
|
||||
role="group"
|
||||
aria-label={t("header.langAria")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("lang-btn", locale === "zh-CN" && "active")}
|
||||
@@ -104,6 +110,7 @@ export function HeaderBar() {
|
||||
title={t("header.docsAria")}
|
||||
aria-label={t("header.docsAria")}
|
||||
>
|
||||
<BookOpen size={14} strokeWidth={2} />
|
||||
{t("header.docs")}
|
||||
</Link>
|
||||
|
||||
@@ -113,6 +120,7 @@ export function HeaderBar() {
|
||||
title={trialPromoLabel}
|
||||
aria-label={trialPromoLabel}
|
||||
>
|
||||
<Sparkles size={12} strokeWidth={2} />
|
||||
<span>{trialPromoLabel}</span>
|
||||
</Link>
|
||||
|
||||
@@ -147,12 +155,15 @@ export function HeaderBar() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
|
||||
className={clsx(
|
||||
"refresh-btn",
|
||||
store.loadingState.refresh && "spinning",
|
||||
)}
|
||||
title={t("header.refreshAria")}
|
||||
aria-label={t("header.refreshAria")}
|
||||
onClick={() => void store.refreshAll()}
|
||||
>
|
||||
↻
|
||||
<RotateCw size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -228,30 +228,40 @@ export function HistoryModal() {
|
||||
) : (
|
||||
<div className="modal-content history-modal">
|
||||
<div className="modal-header">
|
||||
<h2 id="history-modal-title">
|
||||
{t("history.title", {
|
||||
city: store.selectedCity?.toUpperCase() || "",
|
||||
})}
|
||||
</h2>
|
||||
{meta?.mode === "preview" ? (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "12px",
|
||||
marginLeft: "12px",
|
||||
}}
|
||||
>
|
||||
{isRecordsLoading
|
||||
? locale === "en-US"
|
||||
? "Loading full records in background..."
|
||||
: "完整历史正在后台补齐..."
|
||||
: meta.hasMore
|
||||
? locale === "en-US"
|
||||
? `Preview ${meta.previewCount}/${meta.fullCount}`
|
||||
: `预览 ${meta.previewCount}/${meta.fullCount}`
|
||||
: null}
|
||||
<div className="modal-title-stack">
|
||||
<div className="modal-overline">
|
||||
<span>{locale === "en-US" ? "Audit workspace" : "对账工作台"}</span>
|
||||
<span className="modal-overline-sep">•</span>
|
||||
<span>{store.selectedCity?.toUpperCase() || ""}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<h2 id="history-modal-title">
|
||||
{t("history.title", {
|
||||
city: store.selectedCity?.toUpperCase() || "",
|
||||
})}
|
||||
</h2>
|
||||
<div className="modal-subtitle">
|
||||
{locale === "en-US"
|
||||
? "Observed highs, DEB path, and historical baseline consistency."
|
||||
: "查看实测最高温、DEB 路径与历史基线是否一致。"}
|
||||
</div>
|
||||
{meta?.mode === "preview" ? (
|
||||
<div className="modal-header-meta">
|
||||
<span className="modal-meta-pill">
|
||||
{isRecordsLoading
|
||||
? locale === "en-US"
|
||||
? "Full records syncing"
|
||||
: "完整记录补齐中"
|
||||
: meta.hasMore
|
||||
? locale === "en-US"
|
||||
? `Preview ${meta.previewCount}/${meta.fullCount}`
|
||||
: `预览 ${meta.previewCount}/${meta.fullCount}`
|
||||
: locale === "en-US"
|
||||
? "Full set loaded"
|
||||
: "完整记录已到齐"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
@@ -263,18 +273,7 @@ export function HistoryModal() {
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{isNoaaSettlement && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid rgba(56, 189, 248, 0.24)",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(14, 165, 233, 0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div className="modal-callout modal-callout-info">
|
||||
{t("lang") === "en-US"
|
||||
? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.`
|
||||
: `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}(${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
|
||||
@@ -316,6 +315,21 @@ export function HistoryModal() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Performance snapshot" : "表现快照"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US"
|
||||
? "Recent settlement performance"
|
||||
: "近期结算表现"}
|
||||
</h3>
|
||||
<div className="modal-section-note">
|
||||
{locale === "en-US"
|
||||
? "Hit rate, MAE, and baseline comparison across recent settled days."
|
||||
: "查看近期已结算样本里的命中率、误差与基线对照。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-stats">
|
||||
{error ? (
|
||||
<span style={{ color: "var(--accent-red)" }}>
|
||||
@@ -371,10 +385,20 @@ export function HistoryModal() {
|
||||
{!error && <HistoryChart />}
|
||||
{!error && settledPeakRows.length > 0 && (
|
||||
<div className="history-peak-reference">
|
||||
<div className="history-peak-reference-title">
|
||||
{locale === "en-US"
|
||||
? "Peak-12h DEB Reference (Approx.)"
|
||||
: "峰值前 12 小时 DEB 参考(近似)"}
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Reference table" : "参考表"}
|
||||
</div>
|
||||
<div className="history-peak-reference-title">
|
||||
{locale === "en-US"
|
||||
? "Peak-12h DEB Reference (Approx.)"
|
||||
: "峰值前 12 小时 DEB 参考(近似)"}
|
||||
</div>
|
||||
<div className="modal-section-note">
|
||||
{locale === "en-US"
|
||||
? "Use peak-minus-12h DEB as a fast sanity check for settled highs."
|
||||
: "用峰值前 12 小时的 DEB 作为历史结算高温的快速校验。"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-peak-reference-scroll">
|
||||
{settledPeakRows.map((row) => (
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
.root :global(.modal-title-stack) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-overline-sep) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.modal-subtitle) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.modal-header-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.modal-meta-pill) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout) {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.root :global(.modal-callout-info) {
|
||||
border-color: rgba(56, 189, 248, 0.24);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.modal-section-heading) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-kicker) {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.modal-section-note) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -485,12 +485,12 @@ export function ProbabilityDistribution({
|
||||
const buyNoText = toPriceCents(bucket.no_buy);
|
||||
const marketTag = buyYesText
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${buyYesText}`
|
||||
: `买 Yes: ${buyYesText}`
|
||||
? `Market ref: ${buyYesText}`
|
||||
: `市场参考: ${buyYesText}`
|
||||
: buyNoText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${buyNoText}`
|
||||
: `买 No: ${buyNoText}`
|
||||
? `Market hedge: ${buyNoText}`
|
||||
: `市场反向: ${buyNoText}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -530,23 +530,23 @@ export function ProbabilityDistribution({
|
||||
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
|
||||
const marketTag = isMarketBucket
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${marketYesText || "--"}`
|
||||
: `买 Yes: ${marketYesText || "--"}`
|
||||
? `Market ref: ${marketYesText || "--"}`
|
||||
: `市场参考: ${marketYesText || "--"}`
|
||||
: marketNoText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${marketNoText}`
|
||||
: `买 No: ${marketNoText}`
|
||||
? `Market hedge: ${marketNoText}`
|
||||
: `市场反向: ${marketNoText}`
|
||||
: null;
|
||||
const yesPriceText = toPriceCents(marketYesPrice);
|
||||
const noPriceText = toPriceCents(marketNoPrice);
|
||||
const marketTagFinal = isMarketBucket
|
||||
? locale === "en-US"
|
||||
? `Buy Yes: ${yesPriceText || "--"}`
|
||||
: `买 Yes: ${yesPriceText || "--"}`
|
||||
? `Market ref: ${yesPriceText || "--"}`
|
||||
: `市场参考: ${yesPriceText || "--"}`
|
||||
: noPriceText
|
||||
? locale === "en-US"
|
||||
? `Buy No: ${noPriceText}`
|
||||
: `买 No: ${noPriceText}`
|
||||
? `Market hedge: ${noPriceText}`
|
||||
: `市场反向: ${noPriceText}`
|
||||
: marketTag;
|
||||
let bucketLabel =
|
||||
bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
import {
|
||||
DashboardStoreProvider,
|
||||
useDashboardStore,
|
||||
@@ -92,7 +95,13 @@ function DashboardScreen() {
|
||||
!activeSummary;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.root,
|
||||
detailChromeStyles.root,
|
||||
modalChromeStyles.root,
|
||||
)}
|
||||
>
|
||||
<MapCanvas />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
toCitySummary,
|
||||
} from "@/lib/dashboard-client";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
@@ -95,6 +99,36 @@ const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
|
||||
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
|
||||
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
|
||||
|
||||
async function buildAuthMeHeaders(): Promise<HeadersInit> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (!hasSupabasePublicEnv()) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { session: cachedSession },
|
||||
} = await getSupabaseBrowserClient().auth.getSession();
|
||||
let accessToken = String(cachedSession?.access_token || "").trim();
|
||||
const expiresAtSec = Number(cachedSession?.expires_at || 0);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (!accessToken || (Number.isFinite(expiresAtSec) && expiresAtSec <= nowSec + 60)) {
|
||||
const {
|
||||
data: { session: refreshedSession },
|
||||
} = await getSupabaseBrowserClient().auth.refreshSession();
|
||||
accessToken = String(refreshedSession?.access_token || accessToken || "").trim();
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
} catch {
|
||||
// The same-origin route can still fall back to cookie-backed auth.
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function countAvailableModels(
|
||||
detail?: CityDetail | null,
|
||||
targetDate?: string | null,
|
||||
@@ -310,14 +344,10 @@ export function DashboardStoreProvider({
|
||||
);
|
||||
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
||||
const selectedCityRef = useRef<string | null>(null);
|
||||
const selectedDetail =
|
||||
selectedCity && proAccess.subscriptionActive
|
||||
? cityDetailsByName[selectedCity] || null
|
||||
: null;
|
||||
const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null;
|
||||
useEffect(() => {
|
||||
if (proAccess.loading) return;
|
||||
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
|
||||
dashboardClient.clearCityDetailCache();
|
||||
return;
|
||||
}
|
||||
dashboardClient.writeCityDetailCacheBundle(
|
||||
@@ -376,8 +406,6 @@ export function DashboardStoreProvider({
|
||||
if (proAccess.loading) return;
|
||||
if (proAccess.authenticated && proAccess.subscriptionActive) return;
|
||||
dashboardClient.clearCityDetailCache();
|
||||
setCityDetailsByName({});
|
||||
setCityDetailMetaByName({});
|
||||
}, [proAccess]);
|
||||
|
||||
const scheduleBackgroundDetailRefresh = (
|
||||
@@ -524,7 +552,6 @@ export function DashboardStoreProvider({
|
||||
if (proAccess.loading) return;
|
||||
if (!selectedCity) return;
|
||||
if (!isPanelOpen) return;
|
||||
if (!proAccess.authenticated || !proAccess.subscriptionActive) return;
|
||||
if (cityDetailsByName[selectedCity]) return;
|
||||
|
||||
let cancelled = false;
|
||||
@@ -570,11 +597,10 @@ export function DashboardStoreProvider({
|
||||
error: null,
|
||||
}));
|
||||
try {
|
||||
const headers = await buildAuthMeHeaders();
|
||||
const response = await fetch("/api/auth/me", {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -625,6 +651,25 @@ export function DashboardStoreProvider({
|
||||
void loadCities();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cities.length) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const schedule = () => dashboardClient.sendPriorityWarmHint();
|
||||
const idleCallback = (window as Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
}).requestIdleCallback;
|
||||
if (typeof idleCallback === "function") {
|
||||
const id = idleCallback(schedule, { timeout: 3000 });
|
||||
return () => {
|
||||
if (typeof window.cancelIdleCallback === "function") {
|
||||
window.cancelIdleCallback(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
const timer = window.setTimeout(schedule, 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [cities.length]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshProAccess();
|
||||
}, []);
|
||||
@@ -722,20 +767,14 @@ export function DashboardStoreProvider({
|
||||
|
||||
if (proAccessRef.current.loading) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
const detailPromise = ensureCityDetail(cityName, false, "panel");
|
||||
try {
|
||||
await summaryPromise;
|
||||
} catch {
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const access = proAccessRef.current;
|
||||
if (!access.authenticated || !access.subscriptionActive) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
await summaryPromise;
|
||||
const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]);
|
||||
if (selectedCityRef.current === cityName) {
|
||||
if (detail.status === "fulfilled") {
|
||||
setSelectedForecastDate(detail.value.local_date);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
@@ -749,15 +788,13 @@ export function DashboardStoreProvider({
|
||||
cachedDetail?.local_date,
|
||||
);
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
try {
|
||||
await summaryPromise;
|
||||
} catch {
|
||||
}
|
||||
|
||||
void ensureCityDetail(cityName, needsDetailRefresh, "panel")
|
||||
.then((detail) => {
|
||||
const detailPromise = ensureCityDetail(cityName, needsDetailRefresh, "panel");
|
||||
void Promise.allSettled([summaryPromise, detailPromise])
|
||||
.then(([, detail]) => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
if (detail.status === "fulfilled") {
|
||||
setSelectedForecastDate(detail.value.local_date);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
|
||||
@@ -52,6 +52,40 @@ function getMarkerDisplayOffset(cityName: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function pickMarkerTemperature(
|
||||
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||
) {
|
||||
if (!snapshot) return null;
|
||||
const detail = snapshot as Partial<CityDetail>;
|
||||
const currentSource = String(
|
||||
snapshot.current?.settlement_source ||
|
||||
snapshot.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const currentTemp =
|
||||
currentSource === "nmc" || currentSource.includes("nmc")
|
||||
? null
|
||||
: snapshot.current?.temp;
|
||||
const isNmcStation = (station?: { source_label?: string | null }) =>
|
||||
String(station?.source_label || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes("nmc");
|
||||
const candidates = [
|
||||
currentTemp,
|
||||
detail.airport_current?.temp,
|
||||
isNmcStation(detail.airport_primary) ? null : detail.airport_primary?.temp,
|
||||
detail.mgm_nearby?.[0]?.temp,
|
||||
];
|
||||
for (const value of candidates) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createMarkerIcon(
|
||||
city: CityListItem,
|
||||
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||
@@ -60,8 +94,8 @@ function createMarkerIcon(
|
||||
const label = city.display_name;
|
||||
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
|
||||
const tempText =
|
||||
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
|
||||
const markerTemp = pickMarkerTemperature(snapshot);
|
||||
const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--";
|
||||
const offset = getMarkerDisplayOffset(city.name);
|
||||
const styleAttr =
|
||||
offset.x || offset.y
|
||||
@@ -91,7 +125,7 @@ function getMarkerSignature(
|
||||
city.temp_unit,
|
||||
city.lat,
|
||||
city.lon,
|
||||
snapshot?.current?.temp ?? "",
|
||||
pickMarkerTemperature(snapshot) ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
|
||||
|
||||
type CityCacheMeta = {
|
||||
cachedAt: number;
|
||||
@@ -142,6 +143,24 @@ export const dashboardClient = {
|
||||
return data.cities || [];
|
||||
},
|
||||
|
||||
sendPriorityWarmHint(timezone?: string | null) {
|
||||
if (!isClient()) return;
|
||||
const tz = String(
|
||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
).trim();
|
||||
if (!tz) return;
|
||||
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
|
||||
if (window.sessionStorage.getItem(cacheKey)) return;
|
||||
window.sessionStorage.setItem(cacheKey, "1");
|
||||
const params = new URLSearchParams({ timezone: tz });
|
||||
void fetch(`/api/system/priority-warm?${params.toString()}`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
async getCitySummary(cityName: string, options?: { force?: boolean }) {
|
||||
const force = options?.force ?? false;
|
||||
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||
|
||||
@@ -126,6 +126,47 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
manila: [
|
||||
{
|
||||
label: "Wunderground RPLL",
|
||||
href: "https://www.wunderground.com/history/daily/ph/manila/RPLL",
|
||||
kind: "agency",
|
||||
},
|
||||
{
|
||||
label: "Ninoy Aquino International Airport",
|
||||
href: "https://www.newnaia.com.ph/",
|
||||
kind: "airport",
|
||||
},
|
||||
{
|
||||
label: "RPLL METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=RPLL&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
karachi: [
|
||||
{
|
||||
label: "Wunderground OPKC",
|
||||
href: "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
|
||||
kind: "agency",
|
||||
},
|
||||
{
|
||||
label: "Jinnah International Airport",
|
||||
href: "https://www.caapakistan.com.pk/",
|
||||
kind: "airport",
|
||||
},
|
||||
{
|
||||
label: "OPKC METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=OPKC&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
"masroor air base": [
|
||||
{
|
||||
label: "OPMR METAR",
|
||||
href: "https://aviationweather.gov/data/metar/?id=OPMR&decoded=1&taf=1",
|
||||
kind: "metar",
|
||||
},
|
||||
],
|
||||
istanbul: [
|
||||
{
|
||||
label: "MGM",
|
||||
|
||||
@@ -316,6 +316,31 @@ export interface MarketScan {
|
||||
websocket?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IntradayMeteorologySignal {
|
||||
label?: string | null;
|
||||
label_en?: string | null;
|
||||
direction?: "support" | "suppress" | "neutral" | string | null;
|
||||
strength?: "weak" | "medium" | "strong" | string | null;
|
||||
summary?: string | null;
|
||||
summary_en?: string | null;
|
||||
}
|
||||
|
||||
export interface IntradayMeteorology {
|
||||
headline?: string | null;
|
||||
headline_en?: string | null;
|
||||
confidence?: "low" | "medium" | "high" | string | null;
|
||||
base_case_bucket?: string | null;
|
||||
upside_bucket?: string | null;
|
||||
downside_bucket?: string | null;
|
||||
next_observation_time?: string | null;
|
||||
peak_window?: string | null;
|
||||
invalidation_rules?: string[] | null;
|
||||
invalidation_rules_en?: string[] | null;
|
||||
confirmation_rules?: string[] | null;
|
||||
confirmation_rules_en?: string[] | null;
|
||||
signal_contributions?: IntradayMeteorologySignal[] | null;
|
||||
}
|
||||
|
||||
export interface AiAnalysisStructured {
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
@@ -473,6 +498,7 @@ export interface CityDetail {
|
||||
multi_model_daily?: Record<string, DailyModelForecast>;
|
||||
source_forecasts?: SourceForecasts;
|
||||
market_scan?: MarketScan;
|
||||
intraday_meteorology?: IntradayMeteorology;
|
||||
}
|
||||
|
||||
export interface HistoryPoint {
|
||||
|
||||
+284
-15
@@ -312,6 +312,89 @@ function buildObservationPoints(items: Array<{ time?: string; temp?: number | nu
|
||||
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
|
||||
}
|
||||
|
||||
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftMinutes = hmToMinutes(left.time);
|
||||
const rightMinutes = hmToMinutes(right.time);
|
||||
if (leftMinutes == null && rightMinutes == null) return 0;
|
||||
if (leftMinutes == null) return 1;
|
||||
if (rightMinutes == null) return -1;
|
||||
return leftMinutes - rightMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeObservationTimeForChart(
|
||||
value: unknown,
|
||||
detail: CityDetail,
|
||||
) {
|
||||
const raw = String(value || "").trim();
|
||||
if (raw && !raw.includes("T")) {
|
||||
return normalizeHm(raw) || raw;
|
||||
}
|
||||
return normalizeHm(detail.local_time) || normalizeHm(raw) || raw;
|
||||
}
|
||||
|
||||
function buildCurrentObservationFallback(
|
||||
detail: CityDetail,
|
||||
): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> {
|
||||
const candidates: Array<{
|
||||
sourceLabel?: string | null;
|
||||
temp?: number | null;
|
||||
time?: string | null;
|
||||
}> = [
|
||||
{
|
||||
sourceLabel: detail.current?.settlement_source_label,
|
||||
temp: detail.current?.temp,
|
||||
time: detail.current?.obs_time || detail.current?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_primary?.source_label || "METAR",
|
||||
temp: detail.airport_primary?.temp,
|
||||
time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_current?.source_label || "METAR",
|
||||
temp: detail.airport_current?.temp,
|
||||
time: detail.airport_current?.obs_time || detail.airport_current?.report_time,
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.center_station_candidate?.source_label ||
|
||||
detail.center_station_candidate?.source_code,
|
||||
temp: detail.center_station_candidate?.temp,
|
||||
time: String((detail.center_station_candidate as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.official_nearby?.[0]?.source_label ||
|
||||
detail.official_nearby?.[0]?.source_code,
|
||||
temp: detail.official_nearby?.[0]?.temp,
|
||||
time: String((detail.official_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
{
|
||||
sourceLabel:
|
||||
detail.mgm_nearby?.[0]?.source_label ||
|
||||
detail.mgm_nearby?.[0]?.source_code,
|
||||
temp: detail.mgm_nearby?.[0]?.temp,
|
||||
time: String((detail.mgm_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
|
||||
},
|
||||
];
|
||||
|
||||
const first = candidates.find((item) => {
|
||||
const numeric = Number(item.temp);
|
||||
return Number.isFinite(numeric);
|
||||
});
|
||||
if (!first) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
sourceLabel: first.sourceLabel,
|
||||
temp: Number(first.temp),
|
||||
time: normalizeObservationTimeForChart(first.time, detail),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function interpolateSeriesAtMinutes(
|
||||
times: string[],
|
||||
values: Array<number | null | undefined>,
|
||||
@@ -612,9 +695,38 @@ export function getTemperatureChartData(
|
||||
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
|
||||
: []
|
||||
: [];
|
||||
const metarObservationSource = detail.metar_today_obs?.length
|
||||
? detail.metar_today_obs
|
||||
: detail.trend?.recent || [];
|
||||
const currentObservationFallback = buildCurrentObservationFallback(detail);
|
||||
const minPlausibleObservationTemp = (() => {
|
||||
const name = String(detail.name || "").trim().toLowerCase();
|
||||
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
|
||||
if (name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR") {
|
||||
return detail.temp_symbol === "°F" ? 41 : 5;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const filterPlausibleObservations = <T extends { temp?: number | null }>(
|
||||
rows?: T[] | null,
|
||||
) =>
|
||||
(Array.isArray(rows) ? rows : []).filter((row) => {
|
||||
const value = Number(row?.temp);
|
||||
if (!Number.isFinite(value)) return false;
|
||||
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp;
|
||||
});
|
||||
const plausibleMetarTodayObs = filterPlausibleObservations(detail.metar_today_obs);
|
||||
const plausibleTrendRecent = filterPlausibleObservations(detail.trend?.recent);
|
||||
const plausibleCurrentFallback = filterPlausibleObservations(currentObservationFallback);
|
||||
const metarObservationSource = plausibleMetarTodayObs.length
|
||||
? plausibleMetarTodayObs
|
||||
: plausibleTrendRecent.length
|
||||
? plausibleTrendRecent
|
||||
: plausibleCurrentFallback;
|
||||
const usingCurrentObservationFallback =
|
||||
!plausibleMetarTodayObs.length &&
|
||||
!plausibleTrendRecent.length &&
|
||||
plausibleCurrentFallback.length > 0;
|
||||
const currentFallbackTag =
|
||||
currentObservationFallback[0]?.sourceLabel ||
|
||||
getObservationSourceTag(detail);
|
||||
const allowMetarFallback = settlementSource && observationCode !== "hko";
|
||||
const shouldUseMetarFallback =
|
||||
allowMetarFallback &&
|
||||
@@ -633,7 +745,9 @@ export function getTemperatureChartData(
|
||||
return `${icao} METAR`;
|
||||
})();
|
||||
const observationDisplayTag =
|
||||
observationCode === "wunderground"
|
||||
usingCurrentObservationFallback
|
||||
? String(currentFallbackTag).toUpperCase()
|
||||
: observationCode === "wunderground"
|
||||
? metarFallbackTag
|
||||
: useSettlementObservationSource && shouldUseMetarFallback
|
||||
? metarFallbackTag
|
||||
@@ -869,21 +983,20 @@ export function getTemperatureChartData(
|
||||
);
|
||||
}
|
||||
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
|
||||
const recentData =
|
||||
const recentData = sortObservationItemsByTime(
|
||||
observationSource.length > 0
|
||||
? [...observationSource]
|
||||
: [...(detail.trend?.recent || [])];
|
||||
: [...(detail.trend?.recent || [])],
|
||||
);
|
||||
const recentText = recentData
|
||||
.slice(0, 4)
|
||||
.reverse()
|
||||
.slice(-4)
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(`${observationDisplayTag}: ${recentText}`);
|
||||
}
|
||||
if (airportMetarSource.length > 0) {
|
||||
const airportRecentText = [...airportMetarSource]
|
||||
const airportRecentText = sortObservationItemsByTime([...airportMetarSource])
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||
.join(" -> ");
|
||||
legendParts.push(
|
||||
@@ -2827,9 +2940,164 @@ export function getHistorySummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function firstFiniteNumber(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const numeric = toFiniteNumber(value);
|
||||
if (numeric != null) return numeric;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatObservationUpdate(value: unknown, locale: Locale) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const looksDated =
|
||||
/^\d{4}-\d{2}-\d{2}/.test(raw) ||
|
||||
raw.includes("T") ||
|
||||
/[+-]\d{2}:?\d{2}$/.test(raw);
|
||||
if (looksDated) {
|
||||
const parsed = new Date(raw);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return new Intl.DateTimeFormat(isEnglish(locale) ? "en-US" : "zh-CN", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
})
|
||||
.format(parsed)
|
||||
.replace(",", "");
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeHm(raw) || raw;
|
||||
}
|
||||
|
||||
function getOfficialObservationCandidates(detail: CityDetail) {
|
||||
const officialNearby = Array.isArray(detail.official_nearby)
|
||||
? detail.official_nearby
|
||||
: [];
|
||||
const mgmNearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||||
const officialAnchor =
|
||||
officialNearby.find(
|
||||
(station) => station.is_settlement_anchor || station.is_airport_station,
|
||||
) || officialNearby[0];
|
||||
|
||||
return {
|
||||
centerStation: detail.center_station_candidate,
|
||||
mgmNearby,
|
||||
officialAnchor,
|
||||
officialNearby,
|
||||
};
|
||||
}
|
||||
|
||||
function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) {
|
||||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||||
const currentSource = String(
|
||||
detail.current?.settlement_source ||
|
||||
detail.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||||
const isNmcAirport = String(detail.airport_primary?.source_label || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes("nmc");
|
||||
const candidates = [
|
||||
{
|
||||
sourceLabel: detail.airport_current?.source_label || "METAR",
|
||||
temp: detail.airport_current?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.airport_primary?.source_label || "METAR",
|
||||
temp: isNmcAirport ? null : detail.airport_primary?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: detail.current?.settlement_source_label,
|
||||
temp: isNmcCurrent ? null : detail.current?.temp,
|
||||
},
|
||||
{
|
||||
sourceLabel: mgmNearby[0]?.source_label,
|
||||
temp: mgmNearby[0]?.temp,
|
||||
},
|
||||
];
|
||||
const selected = candidates.find((candidate) =>
|
||||
Number.isFinite(Number(candidate.temp)),
|
||||
);
|
||||
|
||||
if (!selected) {
|
||||
return isEnglish(locale) ? "Unavailable" : "未提供";
|
||||
}
|
||||
|
||||
const observedTemp = Number(selected.temp);
|
||||
const sourceLabel = firstNonEmptyString([
|
||||
selected.sourceLabel,
|
||||
getObservationSourceTag(detail),
|
||||
]);
|
||||
const rounded =
|
||||
Math.abs(observedTemp - Math.round(observedTemp)) < 0.05
|
||||
? String(Math.round(observedTemp))
|
||||
: observedTemp.toFixed(1);
|
||||
|
||||
return `${rounded}${detail.temp_symbol || "°C"}${
|
||||
sourceLabel ? ` · ${sourceLabel}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function getObservationUpdateProfile(detail: CityDetail, locale: Locale) {
|
||||
const { mgmNearby } = getOfficialObservationCandidates(detail);
|
||||
const mgmFirstRecord = asRecord(mgmNearby[0]);
|
||||
const currentSource = String(
|
||||
detail.current?.settlement_source ||
|
||||
detail.current?.settlement_source_label ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
|
||||
const rawValue = firstNonEmptyString([
|
||||
detail.airport_primary?.obs_time,
|
||||
detail.airport_primary?.report_time,
|
||||
detail.airport_current?.obs_time,
|
||||
detail.airport_current?.report_time,
|
||||
isNmcCurrent ? "" : detail.current?.obs_time,
|
||||
isNmcCurrent ? "" : detail.current?.report_time,
|
||||
mgmFirstRecord?.obs_time,
|
||||
mgmFirstRecord?.report_time,
|
||||
mgmFirstRecord?.time,
|
||||
detail.updated_at,
|
||||
]);
|
||||
|
||||
return (
|
||||
formatObservationUpdate(rawValue, locale) ||
|
||||
(isEnglish(locale) ? "Unavailable" : "未提供")
|
||||
);
|
||||
}
|
||||
|
||||
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
|
||||
const risk = detail.risk || {};
|
||||
const current = detail.current || {};
|
||||
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||
const nearbySource = String(detail.nearby_source || "").trim().toLowerCase();
|
||||
const sourceCode = getObservationSourceCode(detail);
|
||||
@@ -2901,12 +3169,13 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
|
||||
? "Not marked"
|
||||
: "未标注",
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Observed temp" : "实测温度",
|
||||
value: getObservedTemperatureProfile(detail, locale),
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Observation update" : "观测更新",
|
||||
value:
|
||||
current.obs_time ||
|
||||
detail.updated_at ||
|
||||
(isEnglish(locale) ? "Unavailable" : "未提供"),
|
||||
value: getObservationUpdateProfile(detail, locale),
|
||||
},
|
||||
{
|
||||
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
|
||||
|
||||
@@ -10,6 +10,7 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
/* Legacy shadcn/ui HSL tokens */
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: "hsl(var(--card))",
|
||||
@@ -21,9 +22,60 @@ const config: Config = {
|
||||
accent: "hsl(var(--accent))",
|
||||
"accent-foreground": "hsl(var(--accent-foreground))",
|
||||
border: "hsl(var(--border))",
|
||||
|
||||
/* PolyWeather Design Tokens — direct CSS var refs */
|
||||
"pw-bg": {
|
||||
base: "var(--color-bg-base)",
|
||||
raised: "var(--color-bg-raised)",
|
||||
overlay: "var(--color-bg-overlay)",
|
||||
card: "var(--color-bg-card)",
|
||||
input: "var(--color-bg-input)",
|
||||
},
|
||||
"pw-text": {
|
||||
primary: "var(--color-text-primary)",
|
||||
secondary: "var(--color-text-secondary)",
|
||||
muted: "var(--color-text-muted)",
|
||||
disabled: "var(--color-text-disabled)",
|
||||
},
|
||||
"pw-accent": {
|
||||
primary: "var(--color-accent-primary)",
|
||||
secondary: "var(--color-accent-secondary)",
|
||||
tertiary: "var(--color-accent-tertiary)",
|
||||
},
|
||||
"pw-signal": {
|
||||
success: "var(--color-signal-success)",
|
||||
warning: "var(--color-signal-warning)",
|
||||
danger: "var(--color-signal-danger)",
|
||||
info: "var(--color-signal-info)",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
data: ["var(--font-data)"],
|
||||
display: ["var(--font-display)"],
|
||||
mono: ["var(--font-mono)"],
|
||||
},
|
||||
spacing: {
|
||||
"pw-1": "var(--space-1)",
|
||||
"pw-2": "var(--space-2)",
|
||||
"pw-3": "var(--space-3)",
|
||||
"pw-4": "var(--space-4)",
|
||||
"pw-5": "var(--space-5)",
|
||||
"pw-6": "var(--space-6)",
|
||||
"pw-8": "var(--space-8)",
|
||||
},
|
||||
borderRadius: {
|
||||
"pw-sm": "var(--radius-sm)",
|
||||
"pw-md": "var(--radius-md)",
|
||||
"pw-lg": "var(--radius-lg)",
|
||||
"pw-xl": "var(--radius-xl)",
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 0 1px rgba(34, 211, 238, 0.2), 0 8px 32px rgba(8, 47, 73, 0.5)",
|
||||
glow: "0 0 0 1px rgba(0, 229, 255, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
|
||||
"glow-accent": "var(--shadow-glow-accent)",
|
||||
"glow-secondary": "var(--shadow-glow-secondary)",
|
||||
"elevation-1": "var(--shadow-elevation-1)",
|
||||
"elevation-2": "var(--shadow-elevation-2)",
|
||||
"elevation-3": "var(--shadow-elevation-3)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,9 +9,11 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from src.analysis.deb_algorithm import load_history # noqa: E402
|
||||
from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
|
||||
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
|
||||
from scripts.fit_probability_calibration import ( # noqa: E402
|
||||
_default_history_arg,
|
||||
_load_history_with_fallback,
|
||||
)
|
||||
|
||||
|
||||
def _sf(value):
|
||||
@@ -126,7 +128,7 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
history = load_history(args.history_file)
|
||||
history = _load_history_with_fallback(args.history_file)
|
||||
overall = _blank_metrics()
|
||||
by_city = defaultdict(_blank_metrics)
|
||||
by_date = defaultdict(_blank_metrics)
|
||||
|
||||
@@ -18,9 +18,14 @@ from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
|
||||
from scripts.fit_probability_calibration import ( # noqa: E402
|
||||
_default_history_arg,
|
||||
_extract_samples,
|
||||
_load_history_with_fallback,
|
||||
_load_json_if_exists,
|
||||
_load_legacy_training_samples,
|
||||
_load_snapshot_rows,
|
||||
_load_training_feature_history,
|
||||
_load_truth_history,
|
||||
merge_samples_with_legacy_archive,
|
||||
)
|
||||
from src.analysis.deb_algorithm import load_history # noqa: E402
|
||||
|
||||
|
||||
def _mean(values):
|
||||
@@ -93,12 +98,20 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
history = load_history(args.history_file)
|
||||
history = _load_history_with_fallback(args.history_file)
|
||||
training_feature_history = _load_training_feature_history()
|
||||
truth_history = _load_truth_history()
|
||||
settlement_history = _load_json_if_exists(args.settlement_history)
|
||||
snapshot_rows = _load_snapshot_rows(None)
|
||||
legacy_training_samples = _load_legacy_training_samples()
|
||||
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,
|
||||
)
|
||||
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
|
||||
|
||||
legacy_crps = []
|
||||
emos_crps = []
|
||||
|
||||
@@ -42,6 +42,15 @@ def _load_json_if_exists(path):
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _legacy_training_samples_path():
|
||||
return os.path.join(
|
||||
PROJECT_ROOT,
|
||||
"artifacts",
|
||||
"probability_calibration",
|
||||
"training_samples.json",
|
||||
)
|
||||
|
||||
|
||||
def _legacy_history_path():
|
||||
return os.path.join(PROJECT_ROOT, "data", "daily_records.json")
|
||||
|
||||
@@ -63,10 +72,10 @@ def _load_history_with_fallback(path):
|
||||
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
return DailyRecordRepository().load_all()
|
||||
return {}
|
||||
data = load_history(path)
|
||||
data = _load_json_if_exists(path)
|
||||
if data:
|
||||
return data
|
||||
return _load_json_if_exists(path)
|
||||
return load_history(path)
|
||||
|
||||
|
||||
def _load_truth_history():
|
||||
@@ -107,6 +116,14 @@ def _load_snapshot_rows(path):
|
||||
return rows
|
||||
|
||||
|
||||
def _load_legacy_training_samples(path=None):
|
||||
payload = _load_json_if_exists(path or _legacy_training_samples_path())
|
||||
rows = payload.get("samples") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
|
||||
|
||||
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 {}
|
||||
@@ -327,6 +344,40 @@ def _extract_samples(history, training_feature_history=None, truth_history=None,
|
||||
return snapshot_samples + daily_samples, snapshot_filled + daily_filled
|
||||
|
||||
|
||||
def merge_samples_with_legacy_archive(samples, legacy_samples=None):
|
||||
merged = []
|
||||
seen = set()
|
||||
for sample in samples or []:
|
||||
if not isinstance(sample, dict):
|
||||
continue
|
||||
key = (
|
||||
str(sample.get("city") or "").strip().lower(),
|
||||
str(sample.get("date") or "").strip(),
|
||||
str(sample.get("sample_source") or "").strip().lower(),
|
||||
)
|
||||
if not key[0] or not key[1]:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
merged.append(sample)
|
||||
seen.add(key)
|
||||
for sample in legacy_samples or []:
|
||||
if not isinstance(sample, dict):
|
||||
continue
|
||||
key = (
|
||||
str(sample.get("city") or "").strip().lower(),
|
||||
str(sample.get("date") or "").strip(),
|
||||
str(sample.get("sample_source") or "").strip().lower(),
|
||||
)
|
||||
if not key[0] or not key[1]:
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
merged.append(sample)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fit PolyWeather probability calibration parameters.")
|
||||
parser.add_argument(
|
||||
@@ -366,6 +417,7 @@ def main():
|
||||
truth_history = _load_truth_history()
|
||||
settlement_history = _load_json_if_exists(args.settlement_history)
|
||||
snapshot_rows = _load_snapshot_rows(args.snapshot_file)
|
||||
legacy_training_samples = _load_legacy_training_samples()
|
||||
samples, filled_actual_from_history = _extract_samples(
|
||||
history,
|
||||
training_feature_history=training_feature_history,
|
||||
@@ -373,6 +425,7 @@ def main():
|
||||
settlement_history=settlement_history,
|
||||
snapshot_rows=snapshot_rows,
|
||||
)
|
||||
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
|
||||
calibration = fit_calibration(samples, version=args.version)
|
||||
if not samples:
|
||||
calibration = default_calibration_payload(
|
||||
@@ -382,6 +435,7 @@ def main():
|
||||
calibration.setdefault("metrics", {})
|
||||
calibration["metrics"]["filled_actual_from_history"] = filled_actual_from_history
|
||||
calibration["metrics"]["settlement_history_city_count"] = len(settlement_history)
|
||||
calibration["metrics"]["legacy_archive_samples"] = len(legacy_training_samples)
|
||||
try:
|
||||
calibration["source"] = os.path.relpath(args.output, PROJECT_ROOT)
|
||||
except ValueError:
|
||||
|
||||
@@ -7,7 +7,6 @@ from src.analysis.settlement_rounding import apply_city_settlement
|
||||
from loguru import logger
|
||||
from src.database.runtime_state import (
|
||||
DailyRecordRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TrainingFeatureRecordRepository,
|
||||
TruthRecordRepository,
|
||||
@@ -79,7 +78,7 @@ def load_history(filepath):
|
||||
logger.error(f"Error loading daily records from sqlite, fallback to file: {e}")
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
data = _daily_record_repo.load_all()
|
||||
_history_cache = data
|
||||
@@ -113,13 +112,12 @@ def save_history(filepath, data):
|
||||
_history_cache = data
|
||||
mode = get_state_storage_mode()
|
||||
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_daily_record_repo.replace_all(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving daily records to sqlite: {e}")
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
return
|
||||
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
@@ -884,22 +882,21 @@ def update_daily_record(
|
||||
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")
|
||||
# 自动清理:训练特征需要更长窗口,保留最近 180 天的记录
|
||||
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
|
||||
for city in list(data.keys()):
|
||||
old_dates = [d for d in data[city] if d < cutoff]
|
||||
for d in old_dates:
|
||||
del data[city][d]
|
||||
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_daily_record_repo.upsert_record(city_name, date_str, existing)
|
||||
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
|
||||
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
|
||||
_daily_record_repo.delete_older_than(cutoff)
|
||||
except Exception as e:
|
||||
logger.error(f"Error upserting daily record to sqlite city={city_name} date={date_str}: {e}")
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
raise
|
||||
raise
|
||||
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
save_history(history_file, data)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.database.runtime_state import (
|
||||
ProbabilitySnapshotRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TrainingFeatureRecordRepository,
|
||||
get_state_storage_mode,
|
||||
@@ -105,7 +104,7 @@ def load_snapshot_rows_for_day(
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
path = archive_path or os.path.join(root_dir, "data", "probability_training_snapshots.jsonl")
|
||||
if not os.path.exists(path):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return _snapshot_repo.load_rows_by_city_date(city_key, date_key)
|
||||
return []
|
||||
|
||||
@@ -260,7 +259,7 @@ def append_probability_snapshot(
|
||||
return
|
||||
|
||||
mode = get_state_storage_mode()
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_snapshot_repo.append_snapshot(payload)
|
||||
_training_feature_repo.upsert_record(
|
||||
city_key,
|
||||
|
||||
@@ -228,6 +228,60 @@ CITY_REGISTRY = {
|
||||
"distance_km": 12.0,
|
||||
"warning": "市场现按 Wunderground Halim 机场站整度°C口径结算;热带午后阵雨常在峰值前后快速压制气温。",
|
||||
},
|
||||
"manila": {
|
||||
"name": "Manila",
|
||||
"lat": 14.5086,
|
||||
"lon": 121.0198,
|
||||
"icao": "RPLL",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "RPLL",
|
||||
"settlement_station_label": "Manila/Ninoy Aquino International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/ph/manila/RPLL",
|
||||
"tz_offset": 28800,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "尼诺伊·阿基诺国际机场",
|
||||
"distance_km": 10.0,
|
||||
"warning": "市场现按 Wunderground 尼诺伊·阿基诺国际机场站整度°C口径结算;海风、湿热与午后对流会压缩日最高温窗口。",
|
||||
},
|
||||
"karachi": {
|
||||
"name": "Karachi",
|
||||
"lat": 24.9065,
|
||||
"lon": 67.1608,
|
||||
"icao": "OPKC",
|
||||
"settlement_source": "wunderground",
|
||||
"settlement_station_code": "OPKC",
|
||||
"settlement_station_label": "Karachi/Jinnah International Airport Station",
|
||||
"settlement_url": "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
|
||||
"tz_offset": 18000,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": True,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "真纳国际机场",
|
||||
"distance_km": 15.0,
|
||||
"min_plausible_metar_temp_c": 5.0,
|
||||
"warning": "市场现按 Wunderground 真纳国际机场站整度°C口径结算;海风锋、干热输送与尘霾会影响峰值兑现时间。",
|
||||
},
|
||||
"masroor air base": {
|
||||
"name": "Masroor Air Base",
|
||||
"lat": 24.8936,
|
||||
"lon": 66.9388,
|
||||
"icao": "OPMR",
|
||||
"settlement_station_code": "OPMR",
|
||||
"settlement_station_label": "Masroor Air Base METAR",
|
||||
"tz_offset": 18000,
|
||||
"use_fahrenheit": False,
|
||||
"is_major": False,
|
||||
"risk_level": "medium",
|
||||
"risk_emoji": "🟡",
|
||||
"airport_name": "Masroor Air Base",
|
||||
"distance_km": 0.0,
|
||||
"min_plausible_metar_temp_c": 5.0,
|
||||
"warning": "军事机场 METAR 锚点;公开报文可能不连续,若 OPMR 缺报则不要自动替换为 OPKC 结算口径。",
|
||||
},
|
||||
"tokyo": {
|
||||
"name": "Tokyo",
|
||||
"lat": 35.5523,
|
||||
@@ -777,6 +831,9 @@ ALIASES = {
|
||||
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
|
||||
"kul": "kuala lumpur", "wmkk": "kuala lumpur", "sepang": "kuala lumpur",
|
||||
"jkt": "jakarta", "wihh": "jakarta", "halim": "jakarta",
|
||||
"mnl": "manila", "manila": "manila", "rpll": "manila",
|
||||
"khi": "karachi", "karachi": "karachi", "opkc": "karachi", "jinnah": "karachi",
|
||||
"opmr": "masroor air base", "masroor": "masroor air base", "masroor airbase": "masroor air base",
|
||||
"hel": "helsinki", "efhk": "helsinki", "vantaa": "helsinki",
|
||||
"ams": "amsterdam", "eham": "amsterdam", "schiphol": "amsterdam",
|
||||
"pty": "panama city", "mpmg": "panama city", "panamacity": "panama city",
|
||||
@@ -820,6 +877,13 @@ ALIASES = {
|
||||
"吉隆坡": "kuala lumpur",
|
||||
"雅加达": "jakarta",
|
||||
"雅加達": "jakarta",
|
||||
"马尼拉": "manila",
|
||||
"馬尼拉": "manila",
|
||||
"卡拉奇": "karachi",
|
||||
"马斯鲁尔": "masroor air base",
|
||||
"馬斯魯爾": "masroor air base",
|
||||
"马斯鲁尔空军基地": "masroor air base",
|
||||
"馬斯魯爾空軍基地": "masroor air base",
|
||||
"东京": "tokyo",
|
||||
"東京": "tokyo",
|
||||
"特拉维夫": "tel aviv",
|
||||
|
||||
@@ -12,6 +12,45 @@ from src.utils.metrics import record_source_call
|
||||
|
||||
|
||||
class MetarSourceMixin:
|
||||
def _get_min_plausible_metar_temp_c(
|
||||
self, city: str, icao: Optional[str] = None
|
||||
) -> Optional[float]:
|
||||
normalized = str(city or "").strip().lower()
|
||||
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
|
||||
if not city_meta and icao:
|
||||
icao_upper = str(icao or "").strip().upper()
|
||||
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
|
||||
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
|
||||
city_meta = candidate
|
||||
break
|
||||
raw = city_meta.get("min_plausible_metar_temp_c")
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > -80 else None
|
||||
|
||||
def _is_plausible_metar_temp_c(
|
||||
self, temp_c: object, city: str, icao: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
value = float(temp_c)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
minimum = self._get_min_plausible_metar_temp_c(city, icao)
|
||||
if minimum is None:
|
||||
return True
|
||||
if value < minimum:
|
||||
logger.warning(
|
||||
"Discarding implausible METAR temperature city={} icao={} temp_c={} min_c={}",
|
||||
city,
|
||||
icao,
|
||||
value,
|
||||
minimum,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_icao_code(self, city: str) -> Optional[str]:
|
||||
"""根据城市名获取对应的 ICAO 机场代码"""
|
||||
normalized = city.lower().strip()
|
||||
@@ -44,26 +83,52 @@ class MetarSourceMixin:
|
||||
|
||||
try:
|
||||
url = "https://aviationweather.gov/api/data/metar"
|
||||
params = {
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": 24,
|
||||
"_t": int(time.time()),
|
||||
}
|
||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
history_hours = 24
|
||||
|
||||
def _request_metar_records(hours: int, timeout: float) -> list:
|
||||
response = self._http_get(
|
||||
url,
|
||||
params={
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": hours,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
try:
|
||||
data = _request_metar_records(
|
||||
history_hours,
|
||||
getattr(self, "metar_timeout_sec", self.timeout),
|
||||
)
|
||||
except httpx.HTTPError as primary_exc:
|
||||
history_hours = 2
|
||||
logger.warning(
|
||||
f"METAR {icao} 24h 请求失败,尝试 latest fallback: {primary_exc}"
|
||||
)
|
||||
try:
|
||||
data = _request_metar_records(
|
||||
history_hours,
|
||||
getattr(self, "metar_latest_timeout_sec", 2.5),
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise primary_exc
|
||||
|
||||
data = response.json()
|
||||
if not data:
|
||||
return None
|
||||
|
||||
latest = data[0]
|
||||
temp_c = latest.get("temp")
|
||||
if temp_c is not None and not self._is_plausible_metar_temp_c(temp_c, city, icao):
|
||||
temp_c = None
|
||||
dewp_c = latest.get("dewp")
|
||||
|
||||
def _parse_rawob_time(obs):
|
||||
raw = obs.get("rawOb", "")
|
||||
match = re.search(r"(\d{2})(\d{2})(\d{2})Z", raw)
|
||||
match = re.search(r"\b(\d{2})(\d{2})(\d{2})Z\b", raw)
|
||||
if match:
|
||||
_day, hour, minute = (
|
||||
int(match.group(1)),
|
||||
@@ -112,7 +177,11 @@ class MetarSourceMixin:
|
||||
try:
|
||||
if obs_dt_iter >= utc_midnight:
|
||||
temp_value = obs.get("temp")
|
||||
if temp_value is not None and temp_value > max_so_far_c:
|
||||
if (
|
||||
temp_value is not None
|
||||
and self._is_plausible_metar_temp_c(temp_value, city, icao)
|
||||
and temp_value > max_so_far_c
|
||||
):
|
||||
max_so_far_c = temp_value
|
||||
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||
max_temp_time = local_report.strftime("%H:%M")
|
||||
@@ -133,7 +202,11 @@ class MetarSourceMixin:
|
||||
for index, obs in enumerate(data):
|
||||
obs_temp = obs.get("temp")
|
||||
obs_dt_iter = _parse_rawob_time(obs)
|
||||
if obs_temp is not None and obs_dt_iter:
|
||||
if (
|
||||
obs_temp is not None
|
||||
and obs_dt_iter
|
||||
and self._is_plausible_metar_temp_c(obs_temp, city, icao)
|
||||
):
|
||||
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
|
||||
time_str = local_rt.strftime("%H:%M")
|
||||
if obs_dt_iter >= utc_midnight:
|
||||
@@ -199,11 +272,15 @@ class MetarSourceMixin:
|
||||
"today_obs": today_obs,
|
||||
"recent_obs": recent_obs_raw,
|
||||
"unit": unit,
|
||||
"history_hours": history_hours,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
||||
)
|
||||
if temp is not None:
|
||||
logger.info(
|
||||
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
|
||||
)
|
||||
else:
|
||||
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
|
||||
with self._metar_cache_lock:
|
||||
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
|
||||
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
|
||||
@@ -246,9 +323,8 @@ class MetarSourceMixin:
|
||||
"ids": icao,
|
||||
"format": "json",
|
||||
"hours": 24,
|
||||
"_t": int(time.time()),
|
||||
}
|
||||
response = self.session.get(
|
||||
response = self._http_get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=getattr(self, "metar_timeout_sec", self.timeout),
|
||||
|
||||
@@ -7,7 +7,6 @@ import time
|
||||
from loguru import logger
|
||||
from src.database.runtime_state import (
|
||||
OpenMeteoCacheRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
get_state_storage_mode,
|
||||
)
|
||||
@@ -77,7 +76,7 @@ class OpenMeteoCacheMixin:
|
||||
self._disk_cache_last_mtime = current_mtime
|
||||
if loaded:
|
||||
logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})")
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
try:
|
||||
_open_meteo_cache_repo.replace_payload(saved, self._disk_cache_max_age_sec)
|
||||
except Exception as exc:
|
||||
@@ -124,7 +123,7 @@ class OpenMeteoCacheMixin:
|
||||
"multi_model": multi_model_snapshot,
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_open_meteo_cache_repo.replace_payload(payload, self._disk_cache_max_age_sec)
|
||||
self._disk_cache_last_mtime = _open_meteo_cache_repo.latest_updated_at()
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
|
||||
@@ -12,7 +12,6 @@ from loguru import logger
|
||||
|
||||
from src.database.runtime_state import (
|
||||
OfficialIntradayObservationRepository,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
get_state_storage_mode,
|
||||
)
|
||||
@@ -183,7 +182,7 @@ class SettlementSourceMixin:
|
||||
date_str = local_dt.strftime("%Y-%m-%d")
|
||||
time_str = local_dt.strftime("%H:%M")
|
||||
mode = get_state_storage_mode()
|
||||
if mode not in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode != STATE_STORAGE_SQLITE:
|
||||
return [{"time": time_str, "temp": round(float(current_temp), 1)}]
|
||||
|
||||
lock = self._get_settlement_series_lock()
|
||||
|
||||
@@ -61,6 +61,9 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
"singapore": ["WSSS", "WSAP", "WMKK"],
|
||||
"kuala lumpur": ["WMKK", "WMSA"],
|
||||
"jakarta": ["WIHH", "WIII"],
|
||||
"manila": ["RPLL"],
|
||||
"karachi": ["OPKC", "OPMR"],
|
||||
"masroor air base": ["OPMR", "OPKC"],
|
||||
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
|
||||
"tel aviv": ["LLBG"],
|
||||
"milan": ["LIMC", "LIML", "LIME", "LIPO"],
|
||||
@@ -130,12 +133,22 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
self.metar_timeout_sec = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_METAR_TIMEOUT_SEC", "4"))
|
||||
)
|
||||
self.metar_latest_timeout_sec = max(
|
||||
1.0, float(os.getenv("POLYWEATHER_METAR_LATEST_TIMEOUT_SEC", "2.5"))
|
||||
)
|
||||
self.metar_cluster_timeout_sec = max(
|
||||
2.0, float(os.getenv("POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC", "3.5"))
|
||||
)
|
||||
self.user_agent = str(
|
||||
os.getenv(
|
||||
"POLYWEATHER_USER_AGENT",
|
||||
"PolyWeather/1.0 (+https://polyweather-pro.vercel.app)",
|
||||
)
|
||||
).strip()
|
||||
self.session = httpx.Client(
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
|
||||
)
|
||||
self.open_meteo_cache_ttl_sec = int(
|
||||
@@ -235,6 +248,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
proxy=proxy,
|
||||
headers={"User-Agent": self.user_agent},
|
||||
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
|
||||
)
|
||||
logger.info(f"正在使用天气数据代理: {proxy}")
|
||||
@@ -597,6 +611,18 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
"jakarta": "Jakarta",
|
||||
"雅加达": "Jakarta",
|
||||
"雅加達": "Jakarta",
|
||||
"manila": "Manila",
|
||||
"rpll": "Manila",
|
||||
"马尼拉": "Manila",
|
||||
"馬尼拉": "Manila",
|
||||
"karachi": "Karachi",
|
||||
"opkc": "Karachi",
|
||||
"卡拉奇": "Karachi",
|
||||
"masroor": "Masroor Air Base",
|
||||
"masroor air base": "Masroor Air Base",
|
||||
"opmr": "Masroor Air Base",
|
||||
"马斯鲁尔": "Masroor Air Base",
|
||||
"馬斯魯爾": "Masroor Air Base",
|
||||
"helsinki": "Helsinki",
|
||||
"vantaa": "Helsinki",
|
||||
"赫尔辛基": "Helsinki",
|
||||
|
||||
@@ -182,6 +182,64 @@ class DBManager:
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_summary_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_panel_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_nearby_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_market_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS city_history_preview_cache (
|
||||
city TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_at_ts REAL NOT NULL,
|
||||
version TEXT,
|
||||
source_fingerprint TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cache_refresh_locks (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
locked_until_ts REAL NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS payment_audit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -247,6 +305,151 @@ class DBManager:
|
||||
conn.commit()
|
||||
logger.info(f"Database initialized successfully path={self.db_path}")
|
||||
|
||||
def _cache_table_name(self, kind: str) -> Optional[str]:
|
||||
normalized = str(kind or "").strip().lower()
|
||||
if normalized == "summary":
|
||||
return "city_summary_cache"
|
||||
if normalized == "panel":
|
||||
return "city_panel_cache"
|
||||
if normalized == "nearby":
|
||||
return "city_nearby_cache"
|
||||
if normalized == "market":
|
||||
return "city_market_cache"
|
||||
if normalized == "history_preview":
|
||||
return "city_history_preview_cache"
|
||||
return None
|
||||
|
||||
def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]:
|
||||
table = self._cache_table_name(kind)
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not table or not normalized_city:
|
||||
return None
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT city, payload_json, updated_at, updated_at_ts, version, source_fingerprint
|
||||
FROM {table}
|
||||
WHERE city = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_city,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return {
|
||||
"city": str(row["city"] or normalized_city),
|
||||
"payload": payload,
|
||||
"updated_at": str(row["updated_at"] or ""),
|
||||
"updated_at_ts": float(row["updated_at_ts"] or 0.0),
|
||||
"version": str(row["version"] or ""),
|
||||
"source_fingerprint": str(row["source_fingerprint"] or ""),
|
||||
}
|
||||
|
||||
def set_city_cache(
|
||||
self,
|
||||
kind: str,
|
||||
city: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
version: str = "v1",
|
||||
source_fingerprint: Optional[str] = None,
|
||||
) -> None:
|
||||
table = self._cache_table_name(kind)
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not table or not normalized_city or not isinstance(payload, dict):
|
||||
return
|
||||
now = datetime.now().isoformat()
|
||||
now_ts = datetime.now().timestamp()
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {table} (
|
||||
city,
|
||||
payload_json,
|
||||
updated_at,
|
||||
updated_at_ts,
|
||||
version,
|
||||
source_fingerprint
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(city) DO UPDATE SET
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_at_ts = excluded.updated_at_ts,
|
||||
version = excluded.version,
|
||||
source_fingerprint = excluded.source_fingerprint
|
||||
""",
|
||||
(
|
||||
normalized_city,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
now,
|
||||
now_ts,
|
||||
str(version or "v1"),
|
||||
str(source_fingerprint or ""),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def acquire_cache_refresh_lock(
|
||||
self,
|
||||
cache_key: str,
|
||||
*,
|
||||
ttl_sec: int = 120,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
normalized_key = str(cache_key or "").strip().lower()
|
||||
if not normalized_key:
|
||||
return None
|
||||
lock_owner = str(owner or "").strip() or hashlib.sha1(
|
||||
f"{normalized_key}:{datetime.now().timestamp()}".encode("utf-8")
|
||||
).hexdigest()[:12]
|
||||
now_ts = datetime.now().timestamp()
|
||||
locked_until_ts = now_ts + max(15, int(ttl_sec or 120))
|
||||
updated_at = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO cache_refresh_locks (cache_key, locked_until_ts, owner, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
locked_until_ts = excluded.locked_until_ts,
|
||||
owner = excluded.owner,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE cache_refresh_locks.locked_until_ts < ?
|
||||
""",
|
||||
(
|
||||
normalized_key,
|
||||
locked_until_ts,
|
||||
lock_owner,
|
||||
updated_at,
|
||||
now_ts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return lock_owner if int(cursor.rowcount or 0) > 0 else None
|
||||
|
||||
def release_cache_refresh_lock(self, cache_key: str, owner: str) -> None:
|
||||
normalized_key = str(cache_key or "").strip().lower()
|
||||
normalized_owner = str(owner or "").strip()
|
||||
if not normalized_key or not normalized_owner:
|
||||
return
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM cache_refresh_locks
|
||||
WHERE cache_key = ? AND owner = ?
|
||||
""",
|
||||
(normalized_key, normalized_owner),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_payment_runtime_state(self, state_key: str) -> Optional[Dict[str, Any]]:
|
||||
key = str(state_key or "").strip()
|
||||
if not key:
|
||||
|
||||
@@ -16,9 +16,9 @@ from src.database.db_manager import DBManager
|
||||
STATE_STORAGE_FILE = "file"
|
||||
STATE_STORAGE_DUAL = "dual"
|
||||
STATE_STORAGE_SQLITE = "sqlite"
|
||||
DEFAULT_STATE_STORAGE_MODE = STATE_STORAGE_SQLITE
|
||||
VALID_STATE_STORAGE_MODES = {
|
||||
STATE_STORAGE_FILE,
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
}
|
||||
|
||||
@@ -26,12 +26,17 @@ _LOGGED_MODES: set[str] = set()
|
||||
|
||||
|
||||
def get_state_storage_mode() -> str:
|
||||
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or STATE_STORAGE_DUAL).strip().lower()
|
||||
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or DEFAULT_STATE_STORAGE_MODE).strip().lower()
|
||||
if raw == STATE_STORAGE_DUAL:
|
||||
logger.warning(
|
||||
f"POLYWEATHER_STATE_STORAGE_MODE={STATE_STORAGE_DUAL!r} is deprecated, normalize to {STATE_STORAGE_SQLITE}"
|
||||
)
|
||||
raw = STATE_STORAGE_SQLITE
|
||||
if raw not in VALID_STATE_STORAGE_MODES:
|
||||
logger.warning(
|
||||
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {STATE_STORAGE_DUAL}"
|
||||
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {DEFAULT_STATE_STORAGE_MODE}"
|
||||
)
|
||||
raw = STATE_STORAGE_DUAL
|
||||
raw = DEFAULT_STATE_STORAGE_MODE
|
||||
if raw not in _LOGGED_MODES:
|
||||
logger.info(f"runtime state storage mode={raw}")
|
||||
_LOGGED_MODES.add(raw)
|
||||
|
||||
@@ -289,13 +289,17 @@ def build_training_samples(
|
||||
history_data: Optional[Dict[str, Any]] = None,
|
||||
snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
mode = get_state_storage_mode()
|
||||
if isinstance(history_data, dict):
|
||||
runtime_history = history_data
|
||||
elif get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
elif mode == STATE_STORAGE_SQLITE:
|
||||
runtime_history = DailyRecordRepository().load_all()
|
||||
else:
|
||||
runtime_history = load_history(_history_file_path())
|
||||
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
|
||||
if isinstance(history_data, dict):
|
||||
truth_history = runtime_history
|
||||
training_feature_history = {}
|
||||
elif mode == STATE_STORAGE_SQLITE:
|
||||
truth_history = TruthRecordRepository().load_all()
|
||||
training_feature_history = TrainingFeatureRecordRepository().load_all()
|
||||
else:
|
||||
|
||||
@@ -208,7 +208,7 @@ def get_prewarm_runtime_summary() -> Dict[str, Any]:
|
||||
"interval_sec": interval_sec,
|
||||
"jitter_sec": jitter_sec,
|
||||
"include_detail": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
|
||||
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", True),
|
||||
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", False),
|
||||
"force_refresh": _truthy_env("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
|
||||
"thread_alive": bool(_WORKER_THREAD and _WORKER_THREAD.is_alive()) or shared_alive,
|
||||
"heartbeat_age_sec": None if heartbeat_age_sec is None else round(heartbeat_age_sec, 2),
|
||||
@@ -380,7 +380,7 @@ def start_prewarm_worker_thread() -> Optional[threading.Thread]:
|
||||
jitter_sec = int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))
|
||||
force_refresh = str(os.getenv("POLYWEATHER_PREWARM_FORCE_REFRESH") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_detail = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_DETAIL", "true")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "true")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "false")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=run_worker_loop,
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
from src.database.runtime_state import (
|
||||
STATE_STORAGE_DUAL,
|
||||
STATE_STORAGE_SQLITE,
|
||||
TelegramAlertStateRepository,
|
||||
get_state_storage_mode,
|
||||
@@ -208,11 +207,6 @@ def _load_state(path: str) -> Dict[str, Any]:
|
||||
except Exception as exc:
|
||||
logger.error(f"failed to load telegram push state from sqlite: {exc}")
|
||||
if not os.path.exists(path):
|
||||
if mode == STATE_STORAGE_DUAL:
|
||||
try:
|
||||
return _telegram_state_repo.load_state()
|
||||
except Exception:
|
||||
return {"last_by_city": {}, "by_signature": {}}
|
||||
return {"last_by_city": {}, "by_signature": {}}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
@@ -228,7 +222,7 @@ def _load_state(path: str) -> Dict[str, Any]:
|
||||
|
||||
def _save_state(path: str, state: Dict[str, Any]) -> None:
|
||||
mode = get_state_storage_mode()
|
||||
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
_telegram_state_repo.save_state(state)
|
||||
if mode == STATE_STORAGE_SQLITE:
|
||||
return
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
from src.data_collection.country_networks import build_country_network_snapshot
|
||||
from web.analysis_service import _build_city_detail_payload
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
|
||||
from web.core import CITIES
|
||||
|
||||
|
||||
def test_new_south_asia_city_registry_entries_are_wired():
|
||||
assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground"
|
||||
assert CITY_REGISTRY["manila"]["settlement_station_code"] == "RPLL"
|
||||
assert CITY_REGISTRY["karachi"]["settlement_source"] == "wunderground"
|
||||
assert CITY_REGISTRY["karachi"]["settlement_station_code"] == "OPKC"
|
||||
assert CITY_REGISTRY["masroor air base"]["icao"] == "OPMR"
|
||||
assert ALIASES["rpll"] == "manila"
|
||||
assert ALIASES["opkc"] == "karachi"
|
||||
assert ALIASES["opmr"] == "masroor air base"
|
||||
assert CITIES["manila"]["lat"] == CITY_REGISTRY["manila"]["lat"]
|
||||
assert CITIES["karachi"]["lon"] == CITY_REGISTRY["karachi"]["lon"]
|
||||
assert CITIES["masroor air base"]["settlement_source"] == "metar"
|
||||
|
||||
|
||||
def test_turkey_mgm_provider_returns_official_nearby_rows():
|
||||
@@ -186,3 +202,91 @@ def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
||||
assert payload["official"]["airport_primary"]["source_code"] == "metar"
|
||||
assert payload["official"]["official_nearby"][0]["source_code"] == "mgm"
|
||||
assert payload["settlement_station"]["settlement_station_code"] == "LTAC"
|
||||
|
||||
|
||||
def test_intraday_meteorology_supportive_heating_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "12:04",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 38.2, "max_so_far": 38.2},
|
||||
"deb": {"prediction": 40.4},
|
||||
"probabilities": {"distribution": [{"value": 40, "probability": 0.42}]},
|
||||
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
||||
"deviation_monitor": {"direction": "hot", "severity": "strong", "current_delta": 1.9},
|
||||
"vertical_profile_signal": {
|
||||
"heating_setup": "supportive",
|
||||
"summary_zh": "混合层偏深,仍支持午后继续升温。",
|
||||
},
|
||||
"taf": {"signal": {"available": True, "suppression_level": "low", "summary_zh": "TAF 暂未提示强云雨压温。"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert "上修空间" in payload["headline"]
|
||||
assert "upside" in payload["headline_en"].lower()
|
||||
assert payload["confidence"] == "high"
|
||||
assert payload["base_case_bucket"] == "40°C"
|
||||
assert payload["next_observation_time"] == "12:30"
|
||||
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
|
||||
assert all(item.get("summary_en") for item in payload["signal_contributions"])
|
||||
|
||||
|
||||
def test_intraday_meteorology_suppressed_cloud_rain_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "13:10",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 36.0, "max_so_far": 36.5},
|
||||
"deb": {"prediction": 40.2},
|
||||
"probabilities": {"distribution": [{"value": 40, "probability": 0.35}]},
|
||||
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
||||
"deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -2.0},
|
||||
"vertical_profile_signal": {"heating_setup": "suppressed", "suppression_risk": "high"},
|
||||
"taf": {"signal": {"available": True, "suppression_level": "high", "disruption_level": "high"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert "压制" in payload["headline"]
|
||||
assert "capping" in payload["headline_en"].lower()
|
||||
assert payload["confidence"] == "high"
|
||||
assert any("云雨" in rule for rule in payload["invalidation_rules"])
|
||||
assert any("cloud" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
|
||||
|
||||
|
||||
def test_intraday_meteorology_handles_sparse_observations():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "08:00",
|
||||
"temp_symbol": "°C",
|
||||
"current": {},
|
||||
"probabilities": {"distribution": []},
|
||||
"peak": {},
|
||||
"taf": {},
|
||||
"vertical_profile_signal": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["confidence"] == "low"
|
||||
assert payload["next_observation_time"] == "08:30"
|
||||
assert payload["signal_contributions"][0]["label"] == "数据完整性"
|
||||
assert payload["signal_contributions"][0]["label_en"] == "Data completeness"
|
||||
|
||||
|
||||
def test_intraday_meteorology_past_peak_case():
|
||||
payload = _build_intraday_meteorology(
|
||||
{
|
||||
"local_time": "17:20",
|
||||
"temp_symbol": "°C",
|
||||
"current": {"temp": 33.0, "max_so_far": 39.0},
|
||||
"probabilities": {"distribution": [{"value": 39, "probability": 0.5}]},
|
||||
"peak": {"first_h": 13, "last_h": 15, "status": "past"},
|
||||
"deviation_monitor": {"direction": "normal", "severity": "normal", "current_delta": 0.1},
|
||||
}
|
||||
)
|
||||
|
||||
assert "峰值窗口已过" in payload["headline"]
|
||||
assert "passed" in payload["headline_en"].lower()
|
||||
assert payload["base_case_bucket"] == "39°C"
|
||||
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
|
||||
assert any("final" in rule.lower() for rule in payload["invalidation_rules_en"])
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_payment_runtime_endpoint_returns_shape():
|
||||
assert 'recent_audit_events' in payload
|
||||
|
||||
|
||||
def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
|
||||
def _bind_identity(request):
|
||||
@@ -147,11 +147,10 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
|
||||
calls = {"count": 0}
|
||||
reconcile_calls = {"count": 0}
|
||||
|
||||
def _latest_subscription(user_id, respect_requirement=False):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
return None
|
||||
return {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
@@ -164,10 +163,15 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
_latest_subscription,
|
||||
)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
|
||||
|
||||
def _reconcile_latest_intent(user_id):
|
||||
reconcile_calls["count"] += 1
|
||||
return {"ok": True, "action": "reconciled_confirmed_intent"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.PAYMENT_CHECKOUT,
|
||||
"reconcile_latest_intent",
|
||||
lambda user_id: {"ok": True, "action": "reconciled_confirmed_intent"},
|
||||
_reconcile_latest_intent,
|
||||
)
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
@@ -176,6 +180,7 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
|
||||
payload = response.json()
|
||||
assert payload["subscription_active"] is True
|
||||
assert payload["subscription_plan_code"] == "pro_monthly"
|
||||
assert reconcile_calls["count"] == 0
|
||||
|
||||
|
||||
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
||||
|
||||
+460
-7
@@ -30,6 +30,7 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights
|
||||
from src.analysis.settlement_rounding import apply_city_settlement
|
||||
from src.data_collection.country_networks import build_country_network_snapshot
|
||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
||||
from src.data_collection.nmc_sources import NMC_CITY_REFERENCES
|
||||
from src.models.lgbm_daily_high import predict_lgbm_daily_high
|
||||
|
||||
TURKISH_MGM_CITIES = {"ankara", "istanbul"}
|
||||
@@ -52,6 +53,51 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
|
||||
)
|
||||
|
||||
|
||||
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if "T" in raw:
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone(timedelta(seconds=utc_offset))).strftime("%H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
match = re.search(r"(\d{1,2}):(\d{2})", raw)
|
||||
if match:
|
||||
return f"{int(match.group(1)):02d}:{match.group(2)}"
|
||||
return raw[:16]
|
||||
|
||||
|
||||
def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str, Any]:
|
||||
city_key = str(city or "").strip().lower()
|
||||
if city_key not in NMC_CITY_REFERENCES:
|
||||
return {}
|
||||
try:
|
||||
payload = _weather.fetch_nmc_region_current(
|
||||
city_key,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("NMC current fallback failed city={}: {}", city_key, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C") -> bool:
|
||||
temp = _sf(value)
|
||||
if temp is None:
|
||||
return False
|
||||
meta = CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {}
|
||||
min_c = _sf(meta.get("min_plausible_metar_temp_c"))
|
||||
if min_c is None:
|
||||
return True
|
||||
min_value = min_c * 9 / 5 + 32 if str(unit or "").upper().endswith("F") else min_c
|
||||
return temp >= min_value
|
||||
|
||||
|
||||
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with _ANALYSIS_CACHE_STATS_LOCK:
|
||||
@@ -1084,6 +1130,338 @@ def _build_taf_signal(
|
||||
}
|
||||
|
||||
|
||||
def _clock_minutes(value: Any) -> Optional[int]:
|
||||
text = str(value or "").strip()
|
||||
match = re.search(r"\b(\d{1,2}):(\d{2})\b", text)
|
||||
if not match:
|
||||
return None
|
||||
hour = int(match.group(1))
|
||||
minute = int(match.group(2))
|
||||
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def _format_clock_minutes(value: int) -> str:
|
||||
value = max(0, min(23 * 60 + 59, int(value)))
|
||||
return f"{value // 60:02d}:{value % 60:02d}"
|
||||
|
||||
|
||||
def _next_observation_clock(local_time: Any) -> str:
|
||||
minutes = _clock_minutes(local_time)
|
||||
if minutes is None:
|
||||
return "--"
|
||||
next_slot = ((minutes // 30) + 1) * 30
|
||||
if next_slot > 23 * 60 + 59:
|
||||
return "23:59"
|
||||
return _format_clock_minutes(next_slot)
|
||||
|
||||
|
||||
def _bucket_label_from_value(value: Optional[float], unit: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return f"{int(round(float(value)))}{unit or '°C'}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(distribution, list):
|
||||
return None
|
||||
candidates = [row for row in distribution if isinstance(row, dict)]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda row: _sf(row.get("probability")) or -1.0)
|
||||
|
||||
|
||||
def _bucket_label(row: Optional[Dict[str, Any]], unit: str) -> Optional[str]:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
for key in ("label", "bucket", "range"):
|
||||
raw = str(row.get(key) or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
return _bucket_label_from_value(_sf(row.get("value")), unit)
|
||||
|
||||
|
||||
def _add_signal(
|
||||
signals: list,
|
||||
*,
|
||||
label: str,
|
||||
direction: str,
|
||||
strength: str,
|
||||
summary: str,
|
||||
label_en: Optional[str] = None,
|
||||
summary_en: Optional[str] = None,
|
||||
) -> None:
|
||||
signals.append(
|
||||
{
|
||||
"label": label,
|
||||
"label_en": label_en or label,
|
||||
"direction": direction,
|
||||
"strength": strength,
|
||||
"summary": summary,
|
||||
"summary_en": summary_en or summary,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a paid-product intraday meteorology read from existing layers."""
|
||||
current = data.get("current") or {}
|
||||
probabilities = data.get("probabilities") or {}
|
||||
distribution = probabilities.get("distribution") or []
|
||||
top_bucket = _top_probability_bucket(distribution)
|
||||
unit = str(data.get("temp_symbol") or "°C")
|
||||
deb = data.get("deb") or {}
|
||||
peak = data.get("peak") or {}
|
||||
deviation = data.get("deviation_monitor") or {}
|
||||
taf_signal = (
|
||||
((data.get("taf") or {}).get("signal") or {})
|
||||
if isinstance(data.get("taf"), dict)
|
||||
else {}
|
||||
)
|
||||
vertical = data.get("vertical_profile_signal") or {}
|
||||
|
||||
current_temp = _sf(current.get("temp"))
|
||||
max_so_far = _sf(current.get("max_so_far"))
|
||||
deb_prediction = _sf(deb.get("prediction"))
|
||||
base_value = _sf(top_bucket.get("value")) if isinstance(top_bucket, dict) else None
|
||||
if base_value is None:
|
||||
base_value = deb_prediction
|
||||
if base_value is None:
|
||||
base_value = max_so_far if max_so_far is not None else current_temp
|
||||
|
||||
base_case_bucket = _bucket_label(top_bucket, unit) or _bucket_label_from_value(base_value, unit)
|
||||
upside_bucket = _bucket_label_from_value(base_value + 1.0, unit) if base_value is not None else None
|
||||
downside_bucket = _bucket_label_from_value(base_value - 1.0, unit) if base_value is not None else None
|
||||
|
||||
signals: list = []
|
||||
support_score = 0
|
||||
suppress_score = 0
|
||||
available_layers = 0
|
||||
|
||||
direction = str(deviation.get("direction") or "").lower()
|
||||
severity = str(deviation.get("severity") or "normal").lower()
|
||||
delta = _sf(deviation.get("current_delta"))
|
||||
if direction:
|
||||
available_layers += 1
|
||||
strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak")
|
||||
if direction == "hot":
|
||||
support_score += 2 if strength == "strong" else 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="support",
|
||||
strength=strength,
|
||||
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
|
||||
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.",
|
||||
)
|
||||
elif direction == "cold":
|
||||
suppress_score += 2 if strength == "strong" else 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="suppress",
|
||||
strength=strength,
|
||||
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
|
||||
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.",
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="日内节奏",
|
||||
label_en="Intraday pace",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
|
||||
summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.",
|
||||
)
|
||||
|
||||
heating_setup = str(vertical.get("heating_setup") or "").lower()
|
||||
suppression_risk = str(vertical.get("suppression_risk") or "").lower()
|
||||
if heating_setup or suppression_risk:
|
||||
available_layers += 1
|
||||
if heating_setup == "supportive":
|
||||
support_score += 2
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="support",
|
||||
strength="strong",
|
||||
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
|
||||
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."),
|
||||
)
|
||||
elif heating_setup == "suppressed" or suppression_risk == "high":
|
||||
suppress_score += 2
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="suppress",
|
||||
strength="strong",
|
||||
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
|
||||
summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."),
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="边界层结构",
|
||||
label_en="Boundary-layer setup",
|
||||
direction="neutral",
|
||||
strength="medium",
|
||||
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
|
||||
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."),
|
||||
)
|
||||
|
||||
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
|
||||
taf_disruption = str(taf_signal.get("disruption_level") or "").lower()
|
||||
if taf_signal.get("available") or taf_suppression:
|
||||
available_layers += 1
|
||||
if taf_suppression == "high" or taf_disruption == "high":
|
||||
suppress_score += 2
|
||||
direction_value = "suppress"
|
||||
strength = "strong"
|
||||
elif taf_suppression == "medium" or taf_disruption == "medium":
|
||||
suppress_score += 1
|
||||
direction_value = "suppress"
|
||||
strength = "medium"
|
||||
else:
|
||||
support_score += 1
|
||||
direction_value = "support"
|
||||
strength = "weak"
|
||||
_add_signal(
|
||||
signals,
|
||||
label="TAF 云雨扰动",
|
||||
label_en="TAF cloud/rain disruption",
|
||||
direction=direction_value,
|
||||
strength=strength,
|
||||
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
|
||||
summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."),
|
||||
)
|
||||
|
||||
airport_delta = _sf(data.get("airport_vs_network_delta"))
|
||||
lead_signal = data.get("network_lead_signal") or {}
|
||||
if airport_delta is not None:
|
||||
available_layers += 1
|
||||
leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip()
|
||||
if airport_delta <= -0.4:
|
||||
support_score += 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="support",
|
||||
strength="medium",
|
||||
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}。",
|
||||
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}.",
|
||||
)
|
||||
elif airport_delta >= 0.4:
|
||||
suppress_score += 1
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="suppress",
|
||||
strength="medium",
|
||||
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
|
||||
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports.",
|
||||
)
|
||||
else:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="站网对比",
|
||||
label_en="Station-network comparison",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
|
||||
summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.",
|
||||
)
|
||||
|
||||
peak_status = str(peak.get("status") or "").lower()
|
||||
first_h = _sf(peak.get("first_h"))
|
||||
last_h = _sf(peak.get("last_h"))
|
||||
peak_window = (
|
||||
f"{int(first_h):02d}:00-{int(last_h):02d}:59"
|
||||
if first_h is not None and last_h is not None
|
||||
else "--"
|
||||
)
|
||||
if peak_status == "past":
|
||||
headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
|
||||
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
|
||||
confidence = "high" if available_layers >= 2 else "medium"
|
||||
elif suppress_score >= support_score + 2:
|
||||
headline = "峰值存在云雨或结构压制,当前更偏防守高温上修。"
|
||||
headline_en = "Cloud/rain or structural suppression is capping the peak; defend against aggressive high-temperature upside for now."
|
||||
confidence = "high" if available_layers >= 3 else "medium"
|
||||
elif support_score >= suppress_score + 2:
|
||||
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
|
||||
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
|
||||
confidence = "high" if available_layers >= 3 else "medium"
|
||||
elif available_layers == 0:
|
||||
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
|
||||
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
|
||||
confidence = "low"
|
||||
else:
|
||||
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
|
||||
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
|
||||
confidence = "medium" if available_layers >= 2 else "low"
|
||||
|
||||
next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
|
||||
threshold = base_value
|
||||
invalidation_rules = []
|
||||
invalidation_rules_en = []
|
||||
confirmation_rules = []
|
||||
confirmation_rules_en = []
|
||||
if peak_status == "past":
|
||||
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
|
||||
invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.")
|
||||
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
|
||||
confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.")
|
||||
else:
|
||||
watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
|
||||
if threshold is not None:
|
||||
invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
|
||||
invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.")
|
||||
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
|
||||
confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.")
|
||||
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
|
||||
invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.")
|
||||
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
|
||||
confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.")
|
||||
|
||||
if not signals:
|
||||
_add_signal(
|
||||
signals,
|
||||
label="数据完整性",
|
||||
label_en="Data completeness",
|
||||
direction="neutral",
|
||||
strength="weak",
|
||||
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
|
||||
summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.",
|
||||
)
|
||||
|
||||
return {
|
||||
"headline": headline,
|
||||
"headline_en": headline_en,
|
||||
"confidence": confidence,
|
||||
"base_case_bucket": base_case_bucket,
|
||||
"upside_bucket": upside_bucket,
|
||||
"downside_bucket": downside_bucket,
|
||||
"next_observation_time": next_observation,
|
||||
"peak_window": peak_window,
|
||||
"invalidation_rules": invalidation_rules[:4],
|
||||
"invalidation_rules_en": invalidation_rules_en[:4],
|
||||
"confirmation_rules": confirmation_rules[:3],
|
||||
"confirmation_rules_en": confirmation_rules_en[:3],
|
||||
"signal_contributions": signals[:5],
|
||||
}
|
||||
|
||||
|
||||
def _analyze(
|
||||
city: str,
|
||||
force_refresh: bool = False,
|
||||
@@ -1176,17 +1554,45 @@ def _analyze(
|
||||
sc_cur = settlement_current.get("current", {}) if settlement_current else {}
|
||||
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
|
||||
primary_current = sc_cur if use_settlement_current else mc
|
||||
current_source = settlement_source
|
||||
current_source_label = settlement_source_label
|
||||
current_station_code = settlement_current.get("station_code")
|
||||
current_station_name = settlement_current.get("station_name")
|
||||
cur_temp = _sf(primary_current.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
cur_temp = _sf(mc.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
cur_temp = _sf(mg_cur.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||
nmc_cur = nmc_fallback.get("current") or {}
|
||||
nmc_temp = _sf(nmc_cur.get("temp"))
|
||||
if nmc_temp is not None:
|
||||
cur_temp = nmc_temp
|
||||
current_source = "nmc"
|
||||
current_source_label = "NMC"
|
||||
current_station_code = nmc_fallback.get("station_code")
|
||||
current_station_name = nmc_fallback.get("station_name")
|
||||
|
||||
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = _sf(mc.get("max_temp_so_far"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = cur_temp
|
||||
|
||||
max_temp_time = primary_current.get("max_temp_time")
|
||||
if not max_temp_time and not use_settlement_current:
|
||||
@@ -1237,6 +1643,12 @@ def _analyze(
|
||||
)
|
||||
except Exception:
|
||||
obs_time_str = str(obs_t)[:16]
|
||||
if not obs_time_str and current_source == "nmc":
|
||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||
obs_time_str = _format_observation_time_local(
|
||||
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
|
||||
int(utc_offset or 0),
|
||||
)
|
||||
|
||||
settlement_today_obs = []
|
||||
if use_settlement_current:
|
||||
@@ -1269,8 +1681,14 @@ def _analyze(
|
||||
metar_today_obs_payload = [
|
||||
{"time": t, "temp": v}
|
||||
for t, v in (metar.get("today_obs", []) if metar else [])
|
||||
if _is_plausible_city_temp(city, v, sym)
|
||||
]
|
||||
metar_recent_obs_payload = [
|
||||
point
|
||||
for point in (metar.get("recent_obs", []) if metar else [])
|
||||
if isinstance(point, dict)
|
||||
and _is_plausible_city_temp(city, point.get("temp"), sym)
|
||||
]
|
||||
metar_recent_obs_payload = metar.get("recent_obs", []) if metar else []
|
||||
airport_max_so_far = None
|
||||
airport_max_temp_time = None
|
||||
for point in metar_today_obs_payload:
|
||||
@@ -1826,10 +2244,10 @@ def _analyze(
|
||||
"max_temp_time": max_temp_time,
|
||||
"raw_max_so_far": raw_settlement_max,
|
||||
"wu_settlement": wu_settle,
|
||||
"settlement_source": settlement_source,
|
||||
"settlement_source_label": settlement_source_label,
|
||||
"station_code": settlement_current.get("station_code"),
|
||||
"station_name": settlement_current.get("station_name"),
|
||||
"settlement_source": current_source,
|
||||
"settlement_source_label": current_source_label,
|
||||
"station_code": current_station_code,
|
||||
"station_name": current_station_name,
|
||||
"obs_time": obs_time_str,
|
||||
"obs_age_min": None if use_settlement_current else metar_age_min,
|
||||
"report_time": primary_current.get("report_time"),
|
||||
@@ -1927,6 +2345,7 @@ def _analyze(
|
||||
"ai_analysis": "",
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
result["intraday_meteorology"] = _build_intraday_meteorology(result)
|
||||
|
||||
if include_llm_commentary:
|
||||
result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq(
|
||||
@@ -2033,17 +2452,42 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
|
||||
primary_current = sc_cur if use_settlement_current else mc
|
||||
|
||||
current_source = settlement_source
|
||||
current_source_label = settlement_source_label
|
||||
nmc_fallback: Dict[str, Any] = {}
|
||||
cur_temp = _sf(primary_current.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
cur_temp = _sf(mc.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
cur_temp = _sf(mg_cur.get("temp"))
|
||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||
cur_temp = None
|
||||
if cur_temp is None:
|
||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||
nmc_cur = nmc_fallback.get("current") or {}
|
||||
nmc_temp = _sf(nmc_cur.get("temp"))
|
||||
if nmc_temp is not None:
|
||||
cur_temp = nmc_temp
|
||||
current_source = "nmc"
|
||||
current_source_label = "NMC"
|
||||
|
||||
max_so_far = _sf(primary_current.get("max_temp_so_far"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = _sf(mc.get("max_temp_so_far"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
|
||||
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
|
||||
max_so_far = None
|
||||
if max_so_far is None:
|
||||
max_so_far = cur_temp
|
||||
|
||||
max_temp_time = primary_current.get("max_temp_time")
|
||||
if not max_temp_time and not use_settlement_current:
|
||||
@@ -2084,6 +2528,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
)
|
||||
except Exception:
|
||||
obs_time_str = str(obs_t)[:16]
|
||||
if not obs_time_str and current_source == "nmc":
|
||||
if not nmc_fallback:
|
||||
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
|
||||
obs_time_str = _format_observation_time_local(
|
||||
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
|
||||
int(utc_offset or 0),
|
||||
)
|
||||
|
||||
om_daily = (open_meteo.get("daily") or {}) if isinstance(open_meteo, dict) else {}
|
||||
om_hourly = (open_meteo.get("hourly") or {}) if isinstance(open_meteo, dict) else {}
|
||||
@@ -2200,8 +2651,8 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"max_so_far": _sf(display_settlement_max),
|
||||
"max_temp_time": max_temp_time,
|
||||
"wu_settlement": _sf(wu_settle),
|
||||
"settlement_source": settlement_source,
|
||||
"settlement_source_label": settlement_source_label,
|
||||
"settlement_source": current_source,
|
||||
"settlement_source_label": current_source_label,
|
||||
"obs_time": obs_time_str or None,
|
||||
"obs_age_min": obs_age_min,
|
||||
},
|
||||
@@ -2407,6 +2858,8 @@ def _build_city_detail_payload(
|
||||
},
|
||||
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
||||
"dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
|
||||
"intraday_meteorology": data.get("intraday_meteorology")
|
||||
or _build_intraday_meteorology(data),
|
||||
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
|
||||
"taf": data.get("taf") or {},
|
||||
"market_scan": market_scan,
|
||||
|
||||
+429
-121
@@ -6,12 +6,13 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from loguru import logger
|
||||
|
||||
from src.analysis.deb_algorithm import load_history
|
||||
from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day
|
||||
from src.database.db_manager import DBManager
|
||||
from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository
|
||||
from src.analysis.settlement_rounding import apply_city_settlement
|
||||
from src.data_collection.country_networks import get_country_network_provider
|
||||
@@ -55,6 +56,7 @@ from web.core import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
_CACHE_DB = DBManager()
|
||||
|
||||
_DEB_RECENT_LOOKBACK = 7
|
||||
_DEB_RECENT_MIN_SAMPLES = 3
|
||||
@@ -91,6 +93,278 @@ DEFAULT_PREWARM_CITIES = [
|
||||
"madrid",
|
||||
]
|
||||
HISTORY_PREVIEW_DAY_LIMIT = 21
|
||||
ASIA_CORE_CITIES = [
|
||||
"hong kong",
|
||||
"taipei",
|
||||
"tokyo",
|
||||
"seoul",
|
||||
"busan",
|
||||
"shanghai",
|
||||
"beijing",
|
||||
"guangzhou",
|
||||
"shenzhen",
|
||||
"chongqing",
|
||||
"chengdu",
|
||||
"singapore",
|
||||
"kuala lumpur",
|
||||
"jakarta",
|
||||
]
|
||||
EUROPE_CORE_CITIES = [
|
||||
"istanbul",
|
||||
"ankara",
|
||||
"moscow",
|
||||
"tel aviv",
|
||||
"london",
|
||||
"paris",
|
||||
"madrid",
|
||||
"milan",
|
||||
"warsaw",
|
||||
"amsterdam",
|
||||
"helsinki",
|
||||
]
|
||||
US_CORE_CITIES = [
|
||||
"new york",
|
||||
"los angeles",
|
||||
"san francisco",
|
||||
"austin",
|
||||
"houston",
|
||||
"chicago",
|
||||
"dallas",
|
||||
"miami",
|
||||
"atlanta",
|
||||
"seattle",
|
||||
]
|
||||
CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "180")))
|
||||
CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "300")))
|
||||
CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "480")))
|
||||
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
|
||||
CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max(
|
||||
60,
|
||||
int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")),
|
||||
)
|
||||
CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120")))
|
||||
|
||||
|
||||
def _city_cache_is_fresh(entry: Optional[dict], ttl_sec: int) -> bool:
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
updated_at_ts = float(entry.get("updated_at_ts") or 0.0)
|
||||
if updated_at_ts <= 0:
|
||||
return False
|
||||
return (time.time() - updated_at_ts) < float(ttl_sec)
|
||||
|
||||
|
||||
def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
data = _analyze_summary(city, force_refresh=force_refresh)
|
||||
payload = _build_city_summary_payload(data)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"summary",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:summary",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="panel")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"panel",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:panel",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="nearby")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"nearby",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:nearby",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="market")
|
||||
_CACHE_DB.set_city_cache(
|
||||
"market",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:market",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_city_history_payload(city: str, include_records: bool = False) -> dict:
|
||||
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
||||
truth_rows = _truth_record_repo.load_city(city)
|
||||
feature_rows = _training_feature_repo.load_city(city)
|
||||
|
||||
if not truth_rows and not feature_rows:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
history_file = os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
|
||||
else:
|
||||
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
|
||||
city_data = {}
|
||||
for day in all_dates:
|
||||
record: dict[str, object] = {}
|
||||
truth = truth_rows.get(day) or {}
|
||||
features = feature_rows.get(day) or {}
|
||||
if truth.get("actual_high") is not None:
|
||||
record["actual_high"] = truth.get("actual_high")
|
||||
record["settlement_source"] = truth.get("settlement_source")
|
||||
record["settlement_station_code"] = truth.get("settlement_station_code")
|
||||
record["settlement_station_label"] = truth.get("settlement_station_label")
|
||||
record["truth_version"] = truth.get("truth_version")
|
||||
record["updated_by"] = truth.get("updated_by")
|
||||
record["truth_updated_at"] = truth.get("truth_updated_at")
|
||||
if isinstance(features, dict):
|
||||
if features.get("deb_prediction") is not None:
|
||||
record["deb_prediction"] = features.get("deb_prediction")
|
||||
if features.get("mu") is not None:
|
||||
record["mu"] = features.get("mu")
|
||||
if isinstance(features.get("forecasts"), dict):
|
||||
record["forecasts"] = features.get("forecasts")
|
||||
city_data[day] = record
|
||||
|
||||
if not city_data:
|
||||
return {
|
||||
"history": [],
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": False,
|
||||
"full_count": 0,
|
||||
"preview_count": 0,
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
|
||||
all_days = sorted(city_data.keys())
|
||||
selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
|
||||
out = []
|
||||
for day in selected_days:
|
||||
rec = city_data.get(day, {})
|
||||
if not isinstance(rec, dict):
|
||||
rec = {}
|
||||
|
||||
act = rec.get("actual_high")
|
||||
deb = rec.get("deb_prediction")
|
||||
mu = rec.get("mu")
|
||||
snapshots = load_snapshot_rows_for_day(city, day)
|
||||
peak_ref = _build_peak_minus_12h_reference(
|
||||
actual_high=act,
|
||||
snapshots=snapshots,
|
||||
)
|
||||
forecasts_raw = rec.get("forecasts", {}) or {}
|
||||
forecasts = {}
|
||||
if isinstance(forecasts_raw, dict):
|
||||
for model_name, model_value in forecasts_raw.items():
|
||||
if _is_excluded_model_name(str(model_name)):
|
||||
continue
|
||||
fv = _sf(model_value)
|
||||
forecasts[str(model_name)] = fv if fv is not None else None
|
||||
forecasts = _merge_missing_history_forecasts_from_snapshots(
|
||||
forecasts,
|
||||
snapshots,
|
||||
)
|
||||
mgm = forecasts.get("MGM")
|
||||
out.append(
|
||||
{
|
||||
"date": day,
|
||||
"actual": float(act) if act is not None else None,
|
||||
"deb": float(deb) if deb is not None else None,
|
||||
"mu": float(mu) if mu is not None else None,
|
||||
"mgm": float(mgm) if mgm is not None else None,
|
||||
"forecasts": forecasts,
|
||||
"settlement_source": rec.get("settlement_source"),
|
||||
"settlement_station_code": rec.get("settlement_station_code"),
|
||||
"settlement_station_label": rec.get("settlement_station_label"),
|
||||
"truth_version": rec.get("truth_version"),
|
||||
"updated_by": rec.get("updated_by"),
|
||||
"truth_updated_at": rec.get("truth_updated_at"),
|
||||
"actual_peak_time": peak_ref.get("actual_peak_time"),
|
||||
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
|
||||
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
||||
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"history": out,
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": len(all_days) > len(selected_days),
|
||||
"full_count": len(all_days),
|
||||
"preview_count": len(out),
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
|
||||
|
||||
def _refresh_city_history_preview_cache(city: str) -> dict:
|
||||
payload = _build_city_history_payload(city, include_records=False)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"history_preview",
|
||||
city,
|
||||
payload,
|
||||
version="v1",
|
||||
source_fingerprint=f"{city}:history_preview",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _schedule_cache_refresh(
|
||||
background_tasks: BackgroundTasks,
|
||||
*,
|
||||
kind: str,
|
||||
city: str,
|
||||
force_refresh: bool = False,
|
||||
) -> bool:
|
||||
normalized_kind = str(kind or "").strip().lower()
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city:
|
||||
return False
|
||||
cache_key = f"city:{normalized_kind}:{normalized_city}"
|
||||
owner = _CACHE_DB.acquire_cache_refresh_lock(
|
||||
cache_key,
|
||||
ttl_sec=CACHE_REFRESH_LOCK_TTL_SEC,
|
||||
)
|
||||
if not owner:
|
||||
return False
|
||||
|
||||
def _runner() -> None:
|
||||
try:
|
||||
if normalized_kind == "summary":
|
||||
_refresh_city_summary_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "panel":
|
||||
_refresh_city_panel_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "nearby":
|
||||
_refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh)
|
||||
elif normalized_kind == "history_preview":
|
||||
_refresh_city_history_preview_cache(normalized_city)
|
||||
else:
|
||||
_refresh_city_market_cache(normalized_city, force_refresh=force_refresh)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cache refresh failed kind={} city={} force_refresh={}: {}",
|
||||
normalized_kind,
|
||||
normalized_city,
|
||||
force_refresh,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
_CACHE_DB.release_cache_refresh_lock(cache_key, owner)
|
||||
|
||||
background_tasks.add_task(_runner)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
|
||||
@@ -218,6 +492,33 @@ def _normalize_city_list(raw: Optional[str]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _select_priority_city_batches(client_timezone: Optional[str]) -> dict[str, object]:
|
||||
tz = str(client_timezone or "").strip()
|
||||
normalized = tz.lower()
|
||||
if normalized.startswith("america/"):
|
||||
primary = list(US_CORE_CITIES)
|
||||
secondary = []
|
||||
region = "america"
|
||||
elif normalized.startswith("europe/"):
|
||||
primary = list(EUROPE_CORE_CITIES)
|
||||
secondary = list(ASIA_CORE_CITIES)
|
||||
region = "europe"
|
||||
elif normalized.startswith("asia/") or normalized.startswith("australia/") or normalized.startswith("pacific/"):
|
||||
primary = list(ASIA_CORE_CITIES)
|
||||
secondary = list(EUROPE_CORE_CITIES)
|
||||
region = "asia"
|
||||
else:
|
||||
primary = list(ASIA_CORE_CITIES)
|
||||
secondary = list(EUROPE_CORE_CITIES)
|
||||
region = "default"
|
||||
return {
|
||||
"region": region,
|
||||
"timezone": tz or None,
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
}
|
||||
|
||||
|
||||
def _history_file_path() -> str:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(project_root, "data", "daily_records.json")
|
||||
@@ -317,7 +618,7 @@ async def system_prewarm(
|
||||
for city in selected:
|
||||
city_started = time.perf_counter()
|
||||
try:
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
_refresh_city_summary_cache(city, force_refresh=force_refresh)
|
||||
entry: dict[str, object] = {
|
||||
"city": city,
|
||||
"summary": True,
|
||||
@@ -325,20 +626,11 @@ async def system_prewarm(
|
||||
}
|
||||
summary_ok += 1
|
||||
if include_detail:
|
||||
_build_city_summary_payload(data)
|
||||
_build_city_detail_payload(
|
||||
data,
|
||||
target_date=str(data.get("local_date") or "").strip() or None,
|
||||
)
|
||||
_refresh_city_panel_cache(city, force_refresh=force_refresh)
|
||||
entry["detail"] = True
|
||||
detail_ok += 1
|
||||
if include_market:
|
||||
_build_city_detail_payload(
|
||||
data,
|
||||
target_date=str(data.get("local_date") or "").strip() or None,
|
||||
)
|
||||
entry["market"] = True
|
||||
market_ok += 1
|
||||
entry["market"] = False
|
||||
warmed.append(entry)
|
||||
except Exception as exc:
|
||||
failed.append(
|
||||
@@ -365,6 +657,7 @@ async def system_prewarm(
|
||||
"warmed": warmed,
|
||||
"failed": failed,
|
||||
"summary_ok": summary_ok,
|
||||
"panel_ok": detail_ok,
|
||||
"detail_ok": detail_ok,
|
||||
"market_ok": market_ok,
|
||||
"failed_count": len(failed),
|
||||
@@ -431,6 +724,7 @@ async def list_cities(request: Request):
|
||||
@router.get("/api/city/{name}")
|
||||
async def city_detail(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
depth: str = "panel",
|
||||
@@ -446,128 +740,121 @@ async def city_detail(
|
||||
detail_mode = "nearby"
|
||||
else:
|
||||
detail_mode = "panel"
|
||||
if detail_mode == "panel":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_panel_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="panel", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
|
||||
if detail_mode == "nearby":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_nearby_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="nearby", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
|
||||
if detail_mode == "market":
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_market_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||
return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
|
||||
|
||||
|
||||
@router.get("/api/history/{name}")
|
||||
async def city_history(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
include_records: bool = False,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
city = _normalize_city_or_404(name)
|
||||
if include_records:
|
||||
return await run_in_threadpool(_build_city_history_payload, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="history_preview", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
|
||||
|
||||
def _build_history_payload():
|
||||
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
||||
truth_rows = _truth_record_repo.load_city(city)
|
||||
feature_rows = _training_feature_repo.load_city(city)
|
||||
|
||||
if not truth_rows and not feature_rows:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
history_file = os.path.join(project_root, "data", "daily_records.json")
|
||||
data = load_history(history_file)
|
||||
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
|
||||
else:
|
||||
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
|
||||
city_data = {}
|
||||
for day in all_dates:
|
||||
record: dict[str, object] = {}
|
||||
truth = truth_rows.get(day) or {}
|
||||
features = feature_rows.get(day) or {}
|
||||
if truth.get("actual_high") is not None:
|
||||
record["actual_high"] = truth.get("actual_high")
|
||||
record["settlement_source"] = truth.get("settlement_source")
|
||||
record["settlement_station_code"] = truth.get("settlement_station_code")
|
||||
record["settlement_station_label"] = truth.get("settlement_station_label")
|
||||
record["truth_version"] = truth.get("truth_version")
|
||||
record["updated_by"] = truth.get("updated_by")
|
||||
record["truth_updated_at"] = truth.get("truth_updated_at")
|
||||
if isinstance(features, dict):
|
||||
if features.get("deb_prediction") is not None:
|
||||
record["deb_prediction"] = features.get("deb_prediction")
|
||||
if features.get("mu") is not None:
|
||||
record["mu"] = features.get("mu")
|
||||
if isinstance(features.get("forecasts"), dict):
|
||||
record["forecasts"] = features.get("forecasts")
|
||||
city_data[day] = record
|
||||
|
||||
if not city_data:
|
||||
return {
|
||||
"history": [],
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": False,
|
||||
"full_count": 0,
|
||||
"preview_count": 0,
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
@router.get("/api/system/cache-status")
|
||||
async def system_cache_status(request: Request, cities: Optional[str] = None):
|
||||
_assert_entitlement(request)
|
||||
selected = _normalize_city_list(cities)
|
||||
if not selected:
|
||||
selected = list(DEFAULT_PREWARM_CITIES)
|
||||
kinds = {
|
||||
"summary": CITY_SUMMARY_CACHE_TTL_SEC,
|
||||
"panel": CITY_PANEL_CACHE_TTL_SEC,
|
||||
"nearby": CITY_NEARBY_CACHE_TTL_SEC,
|
||||
"market": CITY_MARKET_CACHE_TTL_SEC,
|
||||
"history_preview": CITY_HISTORY_PREVIEW_CACHE_TTL_SEC,
|
||||
}
|
||||
items = []
|
||||
for city in selected:
|
||||
row = {"city": city}
|
||||
for kind, ttl_sec in kinds.items():
|
||||
entry = _CACHE_DB.get_city_cache(kind, city)
|
||||
row[kind] = {
|
||||
"exists": bool(entry),
|
||||
"fresh": _city_cache_is_fresh(entry, ttl_sec),
|
||||
"updated_at": entry.get("updated_at") if entry else None,
|
||||
"age_sec": round(max(0.0, time.time() - float(entry.get("updated_at_ts") or 0.0)), 1)
|
||||
if entry
|
||||
else None,
|
||||
"ttl_sec": ttl_sec,
|
||||
}
|
||||
items.append(row)
|
||||
return {"cities": items}
|
||||
|
||||
all_days = sorted(city_data.keys())
|
||||
selected_days = (
|
||||
all_days
|
||||
if include_records
|
||||
else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
|
||||
)
|
||||
out = []
|
||||
for day in selected_days:
|
||||
rec = city_data.get(day, {})
|
||||
if not isinstance(rec, dict):
|
||||
rec = {}
|
||||
|
||||
act = rec.get("actual_high")
|
||||
deb = rec.get("deb_prediction")
|
||||
mu = rec.get("mu")
|
||||
snapshots = load_snapshot_rows_for_day(city, day)
|
||||
peak_ref = _build_peak_minus_12h_reference(
|
||||
actual_high=act,
|
||||
snapshots=snapshots,
|
||||
)
|
||||
forecasts_raw = rec.get("forecasts", {}) or {}
|
||||
forecasts = {}
|
||||
if isinstance(forecasts_raw, dict):
|
||||
for model_name, model_value in forecasts_raw.items():
|
||||
if _is_excluded_model_name(str(model_name)):
|
||||
continue
|
||||
fv = _sf(model_value)
|
||||
forecasts[str(model_name)] = fv if fv is not None else None
|
||||
forecasts = _merge_missing_history_forecasts_from_snapshots(
|
||||
forecasts,
|
||||
snapshots,
|
||||
)
|
||||
mgm = forecasts.get("MGM")
|
||||
out.append(
|
||||
{
|
||||
"date": day,
|
||||
"actual": float(act) if act is not None else None,
|
||||
"deb": float(deb) if deb is not None else None,
|
||||
"mu": float(mu) if mu is not None else None,
|
||||
"mgm": float(mgm) if mgm is not None else None,
|
||||
"forecasts": forecasts,
|
||||
"settlement_source": rec.get("settlement_source"),
|
||||
"settlement_station_code": rec.get("settlement_station_code"),
|
||||
"settlement_station_label": rec.get("settlement_station_label"),
|
||||
"truth_version": rec.get("truth_version"),
|
||||
"updated_by": rec.get("updated_by"),
|
||||
"truth_updated_at": rec.get("truth_updated_at"),
|
||||
"actual_peak_time": peak_ref.get("actual_peak_time"),
|
||||
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
|
||||
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
||||
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
|
||||
}
|
||||
)
|
||||
@router.post("/api/system/priority-warm")
|
||||
async def system_priority_warm(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
timezone: Optional[str] = None,
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
batches = _select_priority_city_batches(timezone)
|
||||
primary = list(batches.get("primary") or [])
|
||||
secondary = list(batches.get("secondary") or [])
|
||||
|
||||
return {
|
||||
"history": out,
|
||||
"mode": "full" if include_records else "preview",
|
||||
"has_more": len(all_days) > len(selected_days),
|
||||
"full_count": len(all_days),
|
||||
"preview_count": len(out),
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
|
||||
}
|
||||
def _runner() -> None:
|
||||
for city in primary:
|
||||
try:
|
||||
_refresh_city_summary_cache(city, force_refresh=False)
|
||||
_refresh_city_panel_cache(city, force_refresh=False)
|
||||
_refresh_city_nearby_cache(city, force_refresh=False)
|
||||
_refresh_city_market_cache(city, force_refresh=False)
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
for city in secondary:
|
||||
try:
|
||||
_refresh_city_summary_cache(city, force_refresh=False)
|
||||
_refresh_city_panel_cache(city, force_refresh=False)
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
|
||||
return await run_in_threadpool(_build_history_payload)
|
||||
background_tasks.add_task(_runner)
|
||||
return {
|
||||
"ok": True,
|
||||
"region": batches.get("region"),
|
||||
"timezone": batches.get("timezone"),
|
||||
"primary": primary,
|
||||
"secondary": secondary,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/auth/me")
|
||||
@@ -1063,10 +1350,21 @@ async def payment_reconcile_latest(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/summary")
|
||||
async def city_summary(request: Request, name: str, force_refresh: bool = False):
|
||||
async def city_summary(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = await run_in_threadpool(_analyze_summary, city, force_refresh)
|
||||
return await run_in_threadpool(_build_city_summary_payload, data)
|
||||
if force_refresh:
|
||||
return await run_in_threadpool(_refresh_city_summary_cache, city, True)
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="summary", city=city)
|
||||
return cached_entry.get("payload") or {}
|
||||
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
|
||||
|
||||
|
||||
@router.get("/api/city/{name}/detail")
|
||||
@@ -1091,6 +1389,7 @@ async def city_detail_aggregate(
|
||||
@router.get("/api/city/{name}/market-scan")
|
||||
async def city_market_scan(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str,
|
||||
force_refresh: bool = False,
|
||||
market_slug: Optional[str] = None,
|
||||
@@ -1098,7 +1397,16 @@ async def city_market_scan(
|
||||
):
|
||||
_assert_entitlement(request)
|
||||
city = _normalize_city_or_404(name)
|
||||
data = await run_in_threadpool(_analyze, city, force_refresh, False, "market")
|
||||
if force_refresh:
|
||||
data = await run_in_threadpool(_refresh_city_market_cache, city, True)
|
||||
else:
|
||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
||||
if cached_entry:
|
||||
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
|
||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
||||
data = cached_entry.get("payload") or {}
|
||||
else:
|
||||
data = await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||
return await run_in_threadpool(
|
||||
_build_city_market_scan_payload,
|
||||
data,
|
||||
|
||||
Reference in New Issue
Block a user