diff --git a/.env.example b/.env.example
index 7504f2df..a77cdccc 100644
--- a/.env.example
+++ b/.env.example
@@ -42,6 +42,10 @@ OPEN_METEO_RATE_CACHE_TTL_SEC=3600
OPEN_METEO_MIN_CALL_INTERVAL_SEC=3
METAR_CACHE_TTL_SEC=600
METEOBLUE_CACHE_TTL_SEC=7200
+POLYWEATHER_LGBM_ENABLED=false
+POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
+POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
+POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
########################################
# 4) Auth / entitlement
diff --git a/README.md b/README.md
index 343de0f9..0608183c 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,7 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
- Open-Core policy: [docs/OPEN_CORE_POLICY.md](docs/OPEN_CORE_POLICY.md)
- Supabase setup (ZH): [docs/SUPABASE_SETUP_ZH.md](docs/SUPABASE_SETUP_ZH.md)
- Configuration & secrets (ZH): [docs/CONFIGURATION_ZH.md](docs/CONFIGURATION_ZH.md)
+- LightGBM daily-high model (ZH): [docs/LGBM_DAILY_HIGH_ZH.md](docs/LGBM_DAILY_HIGH_ZH.md)
- Frontend deployment (ZH): [docs/FRONTEND_DEPLOYMENT_ZH.md](docs/FRONTEND_DEPLOYMENT_ZH.md)
- Tech debt (EN): [docs/TECH_DEBT.md](docs/TECH_DEBT.md)
- Tech debt (ZH): [docs/TECH_DEBT_ZH.md](docs/TECH_DEBT_ZH.md)
diff --git a/artifacts/models/lgbm_daily_high.txt b/artifacts/models/lgbm_daily_high.txt
new file mode 100644
index 00000000..2fe1b654
--- /dev/null
+++ b/artifacts/models/lgbm_daily_high.txt
@@ -0,0 +1,1176 @@
+tree
+version=v4
+num_class=1
+num_tree_per_iteration=1
+label_index=0
+max_feature_idx=26
+objective=regression
+feature_names=actual_high_lag_1 actual_high_lag_2 actual_high_lag_3 actual_high_lag_7 actual_high_mean_7 actual_high_mean_14 actual_high_trend_3 open_meteo ecmwf gfs gem jma icon mgm nws deb_prediction model_median model_spread current_temp max_so_far humidity wind_speed_kt visibility_mi local_hour month weekday peak_status_code
+feature_infos=[-6:34] [1:27.799999237060547] none none [-6:34] [-6:34] none [5.5:34.700000762939453] [7.5:33.799999237060547] [5.5999999046325684:37.299999237060547] [7.9000000953674316:33.900001525878906] [4:35.099998474121094] [4.5:34.700000762939453] none none [6.4000000953674316:34.5] [6.3000001907348633:34.700000762939453] [0.60000002384185791:4.5] none none none none none none none [0:3] none
+tree_sizes=622 591 592 556 591 669 590 593 596 575 671 596 593 597 596 598 580 591 598 597 578 680 600 582 670 596 583 618 594 599 599 596 559 664 583 579 507 578 603 580 606 598 623 693 694 604 696 603 771 694 604 689 677 738
+
+Tree=0
+num_leaves=6
+num_cat=0
+split_feature=0 10 25 8 1
+split_gain=1463.02 75 73.5 41.8613 12.5
+threshold=19.000000000000004 15.949999809265138 2.5000000000000004 25.250000000000004 1.0000000180025095e-35
+decision_type=2 2 2 2 10
+left_child=1 2 -1 -2 -4
+right_child=3 -3 4 -5 -6
+leaf_value=16.104051653372828 16.705301631319109 16.17905165262777 15.779051656353062 16.934051657545155 15.904051650392597
+leaf_weight=4.0000000000000044 4 3.9999999999999991 4 4 4
+leaf_count=4 4 4 4 4 4
+internal_value=16.2676 15.9916 15.9291 16.8197 15.8416
+internal_weight=24 16 12 8 8
+internal_count=24 16 12 8 8
+is_linear=0
+shrinkage=1
+
+
+Tree=1
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 25
+split_gain=1127.66 153.56 57.0182 76.0778
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.5000000000000004
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.18114008009433719 0.1772349134087563 0.59287240982055656 -0.15145258456468583 -0.45448828935623165
+leaf_weight=4.0000000000000053 3.9999999999999982 5 4.9999999999999991 7
+leaf_count=4 4 5 5 7
+internal_value=-0.0395976 0.408145 -0.291453 -0.355089
+internal_weight=25 9 16 11
+internal_count=25 9 16 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=2
+num_leaves=5
+num_cat=0
+split_feature=11 8 4 8
+split_gain=1081.38 132.977 65.1979 28.2097
+threshold=16.500000000000004 25.250000000000004 15.750000000000002 14.09999990463257
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.41354513367017093 0.21087317466735844 0.5976537895202636 -0.10612917358521373 -0.24212438017129895
+leaf_weight=6.0000000000000018 3.9999999999999982 5 3.9999999999999991 4
+leaf_count=6 4 5 4 4
+internal_value=-0.00184884 0.425751 -0.276735 -0.344977
+internal_weight=23 9 14 10
+internal_count=23 9 14 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=3
+num_leaves=5
+num_cat=0
+split_feature=0 25 10 9
+split_gain=1056.34 71.4123 103.567 43.8079
+threshold=19.000000000000004 2.5000000000000004 15.949999809265138 25.150000572204593
+decision_type=2 2 2 2
+left_child=1 -1 -3 -2
+right_child=3 2 -4 -5
+leaf_value=-0.14301777064800258 0.30870695859193797 -0.53039961814880365 -0.18906025271862742 0.54271523952484124
+leaf_weight=5.0000000000000009 4 5 4 4
+leaf_count=5 4 5 4 4
+internal_value=-0.03262 -0.294523 -0.378693 0.425711
+internal_weight=22 14 9 8
+internal_count=22 14 9 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=4
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 25
+split_gain=1056.87 158.111 46.2432 62.5271
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.5000000000000004
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.14854045137763003 0.1570641821622849 0.52520016261509483 -0.12284998334944248 -0.39635192666734964
+leaf_weight=4.0000000000000053 4.9999999999999982 7 4.9999999999999991 7
+leaf_count=4 5 7 5 7
+internal_value=0.0171017 0.37181 -0.24893 -0.306239
+internal_weight=28 12 16 11
+internal_count=28 12 16 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=5
+num_leaves=6
+num_cat=0
+split_feature=0 8 0 25 4
+split_gain=948.797 143.384 49.5846 63.7686 13.1343
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.5000000000000004 10.750000000000002
+decision_type=2 2 2 2 2
+left_child=2 -2 3 -1 -5
+right_child=1 -3 -4 4 -6
+leaf_value=-0.14111343137919885 0.14921097218990328 0.51175089677174879 -0.11670748524367809 -0.44968532919883719 -0.32155306190252297
+leaf_weight=4.0000000000000044 4.9999999999999982 6 4.9999999999999991 4 4
+leaf_count=4 5 6 5 4 4
+internal_value=-0.0148709 0.34696 -0.248997 -0.304117 -0.385619
+internal_weight=28 11 17 12 8
+internal_count=28 11 17 12 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=6
+num_leaves=5
+num_cat=0
+split_feature=0 8 7 7
+split_gain=819.851 115.847 59.7187 47.849
+threshold=19.000000000000004 24.450000762939457 12.200000286102297 16.19999980926514
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.36468152403831455 0.13604293018579486 0.47335260936192097 -0.046046725511550916 -0.27806002516299483
+leaf_weight=5.0000000000000027 3.9999999999999982 7 4.9999999999999982 4
+leaf_count=5 4 7 5 4
+internal_value=0.0276703 0.350695 -0.226134 -0.149164
+internal_weight=25 11 14 9
+internal_count=25 11 14 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=7
+num_leaves=5
+num_cat=0
+split_feature=0 8 7 7
+split_gain=657.894 103.374 45.6805 19.6347
+threshold=19.000000000000004 24.450000762939457 16.19999980926514 12.200000286102297
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.22679260492324818 0.13494828253984456 0.4427791198094686 -0.35092775225639344 -0.092634215702613182
+leaf_weight=5.0000000000000018 4.9999999999999982 6 3.9999999999999991 6
+leaf_count=5 5 6 4 6
+internal_value=0.00915142 0.302856 -0.206232 -0.153615
+internal_weight=26 11 15 11
+internal_count=26 11 15 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=8
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 17
+split_gain=753.323 94.7491 44.1349 50.5657
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.14497262001037578 0.1224933743476868 0.42754604135240826 -0.089603995680809026 -0.35316013608660013
+leaf_weight=5.0000000000000053 3.9999999999999982 7 4.9999999999999991 7
+leaf_count=5 4 7 5 7
+internal_value=-0.00579315 0.316618 -0.214412 -0.266415
+internal_weight=28 11 17 12
+internal_count=28 11 17 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=9
+num_leaves=5
+num_cat=0
+split_feature=8 0 25 4
+split_gain=595.458 84.257 29.0752 20.2976
+threshold=18.500000000000004 23.000000000000004 2.5000000000000004 12.500000000000002
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.11161282220855351 0.093005212396383319 0.38067240544727871 -0.34078732252120969 -0.1896755730733275
+leaf_weight=4.0000000000000009 3.9999999999999982 7 5 4
+leaf_count=4 4 7 5 4
+internal_value=0.00531823 0.276066 -0.223776 -0.273627
+internal_weight=24 11 13 9
+internal_count=24 11 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=10
+num_leaves=6
+num_cat=0
+split_feature=0 8 0 25 4
+split_gain=616.333 80.0988 37.2236 44.986 9.21655
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.5000000000000004 10.750000000000002
+decision_type=2 2 2 2 2
+left_child=2 -2 3 -1 -5
+right_child=1 -3 -4 4 -6
+leaf_value=-0.10603218208998429 0.10665611773729328 0.38713511058262418 -0.080563141703605665 -0.36506341695785516 -0.25772895738482471
+leaf_weight=4.0000000000000044 3.9999999999999982 7 4.9999999999999991 4 4
+leaf_count=4 4 7 5 4 4
+internal_value=-0.0064837 0.285143 -0.195183 -0.242942 -0.311396
+internal_weight=28 11 17 12 8
+internal_count=28 11 17 12 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=11
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 17
+split_gain=456.23 66.3429 47.9922 41.3672
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.11966208755969997 0.10632330588996414 0.36920583248138428 -0.023635203391313555 -0.31439250012238817
+leaf_weight=5.0000000000000018 3.9999999999999982 6 3.9999999999999991 6
+leaf_count=5 4 6 4 6
+internal_value=0.00245288 0.264053 -0.171947 -0.225879
+internal_weight=25 10 15 11
+internal_count=25 10 15 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=12
+num_leaves=5
+num_cat=0
+split_feature=0 8 8 7
+split_gain=380.135 53.5006 38.7194 43.2394
+threshold=19.000000000000004 24.450000762939457 12.150000095367433 16.19999980926514
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.29093345642089824 0.097411302104592354 0.34274399757385249 -0.016552985906600957 -0.22449381887912745
+leaf_weight=5.0000000000000027 3.9999999999999982 5 4.9999999999999982 5
+leaf_count=5 4 5 5 5
+internal_value=-0.023189 0.233707 -0.177327 -0.120523
+internal_weight=24 9 15 10
+internal_count=24 9 15 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=13
+num_leaves=5
+num_cat=0
+split_feature=0 8 7 7
+split_gain=426.902 60.0551 51.5531 27.1408
+threshold=19.000000000000004 24.450000762939457 12.200000286102297 16.19999980926514
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.33684623837470978 0.098979862928390519 0.33360833326975503 -0.055538034935792291 -0.21326912164688108
+leaf_weight=4.0000000000000027 4.9999999999999982 6 5.9999999999999982 5
+leaf_count=4 5 6 6 5
+internal_value=-0.00963113 0.226959 -0.183131 -0.127234
+internal_weight=26 11 15 11
+internal_count=26 11 15 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=14
+num_leaves=5
+num_cat=0
+split_feature=0 0 25 11
+split_gain=392.675 35.1328 47.1651 27.7482
+threshold=19.000000000000004 14.500000000000002 2.5000000000000004 24.450000762939457
+decision_type=2 2 2 2
+left_child=1 2 -1 -2
+right_child=3 -3 -4 -5
+leaf_value=-0.081762676686048391 0.14590370357036594 -0.05887834966182709 -0.29699014936174661 0.32258629322051996
+leaf_weight=4.0000000000000053 3.9999999999999982 4.9999999999999991 7 5
+leaf_count=4 4 5 7 5
+internal_value=-0.0201531 -0.168773 -0.218726 0.244061
+internal_weight=25 16 11 9
+internal_count=25 16 11 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=15
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 17
+split_gain=359.462 55.0176 37.0574 31.3127
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.11428530700504774 0.086735686957836178 0.31130820512771606 -0.055934431552886965 -0.29488812138636905
+leaf_weight=4.0000000000000018 4.9999999999999982 6 4.9999999999999991 6
+leaf_count=4 5 6 5 6
+internal_value=-0.00786979 0.20923 -0.167076 -0.222647
+internal_weight=26 11 15 10
+internal_count=26 11 15 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=16
+num_leaves=5
+num_cat=0
+split_feature=7 8 25 17
+split_gain=200.838 20.2155 16.9621 26.0306
+threshold=20.10000038146973 25.250000000000004 2.5000000000000004 3.3500000238418584
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.035383382439613309 0.12206844329833987 0.27287452816963192 -0.21010860602060955 -0.055637337118387212
+leaf_weight=6.0000000000000044 4.9999999999999982 4 6 5
+leaf_count=6 5 4 6 5
+internal_value=-0.00189609 0.189093 -0.103008 -0.139894
+internal_weight=26 9 17 11
+internal_count=26 9 17 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=17
+num_leaves=5
+num_cat=0
+split_feature=0 11 7 0
+split_gain=345.166 44.0337 16.0888 13.2211
+threshold=14.500000000000002 25.60000038146973 12.200000286102297 23.000000000000004
+decision_type=2 2 2 2
+left_child=2 3 -1 -2
+right_child=1 -3 -4 -5
+leaf_value=-0.29089830815792067 0.057816250920295735 0.31159715652465825 -0.16144098651905853 0.172799459695816
+leaf_weight=4.0000000000000018 4.9999999999999982 3.9999999999999991 6 5
+leaf_count=4 5 4 6 5
+internal_value=0.0111345 0.171391 -0.213224 0.115308
+internal_weight=24 14 10 10
+internal_count=24 14 10 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=18
+num_leaves=5
+num_cat=0
+split_feature=7 11 10 17
+split_gain=298.769 24.5761 21.9404 12.7549
+threshold=20.10000038146973 25.60000038146973 15.949999809265138 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.11951845437288276 0.1406556674412319 0.29601731300354001 -0.060399984121322636 -0.23478476256132122
+leaf_weight=4.0000000000000018 6.9999999999999982 4 4.9999999999999991 6
+leaf_count=4 7 4 5 6
+internal_value=-0.000773977 0.197151 -0.145919 -0.188678
+internal_weight=26 11 15 10
+internal_count=26 11 15 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=19
+num_leaves=5
+num_cat=0
+split_feature=0 11 7 7
+split_gain=217.533 18.7754 17.9182 5.66246
+threshold=19.000000000000004 24.450000762939457 16.19999980926514 12.200000286102297
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.096831277236342314 0.12015924453735355 0.2600081086158752 -0.17168876290321353 -0.024785566329956054
+leaf_weight=5.0000000000000053 3.9999999999999982 6 4.9999999999999991 6
+leaf_count=5 4 6 5 6
+internal_value=0.0211297 0.204069 -0.0932071 -0.0575336
+internal_weight=26 10 16 11
+internal_count=26 10 16 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=20
+num_leaves=5
+num_cat=0
+split_feature=7 25 4 8
+split_gain=200.486 30.5483 19.4882 15.5729
+threshold=20.10000038146973 2.5000000000000004 12.500000000000002 25.250000000000004
+decision_type=2 2 2 2
+left_child=1 -1 -3 -2
+right_child=3 2 -4 -5
+leaf_value=-0.021534685914715114 0.11891016662120822 -0.234692770242691 -0.10103578865528105 0.24627477228641506
+leaf_weight=6.0000000000000044 5.9999999999999982 5 6 4
+leaf_count=6 6 5 6 4
+internal_value=-0.00778987 -0.112287 -0.161789 0.169856
+internal_weight=27 17 11 10
+internal_count=27 17 11 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=21
+num_leaves=6
+num_cat=0
+split_feature=0 10 8 25 1
+split_gain=162.942 23.4839 22.1296 13.9857 10.354
+threshold=19.000000000000004 15.949999809265138 24.450000762939457 2.5000000000000004 1.0000000180025095e-35
+decision_type=2 2 2 2 10
+left_child=1 3 -2 -1 -5
+right_child=2 -3 -4 4 -6
+leaf_value=-0.067836675234138874 0.063095346242189418 -0.004281503707170487 0.20552252133687335 -0.23922500908374783 -0.12546000257134435
+leaf_weight=4.0000000000000044 4.9999999999999982 3.9999999999999991 6 4 4
+leaf_count=4 5 4 6 4 4
+internal_value=-0.00735559 -0.109201 0.140783 -0.144174 -0.182343
+internal_weight=27 16 11 12 8
+internal_count=27 16 11 12 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=22
+num_leaves=5
+num_cat=0
+split_feature=0 8 10 17
+split_gain=195.078 26.4115 18.8741 21.2154
+threshold=19.000000000000004 24.450000762939457 15.949999809265138 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.075910968035459436 0.055670683830976501 0.21672922202519007 -0.034816115498542792 -0.2153650792936484
+leaf_weight=5.0000000000000053 3.9999999999999982 7 4.9999999999999991 6
+leaf_count=5 4 7 5 6
+internal_value=-0.00392736 0.158162 -0.115364 -0.151977
+internal_weight=27 11 16 11
+internal_count=27 11 16 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=23
+num_leaves=5
+num_cat=0
+split_feature=11 8 25 1
+split_gain=152.229 15.3166 12.435 25.0131
+threshold=16.500000000000004 25.250000000000004 2.5000000000000004 1.0000000180025095e-35
+decision_type=2 2 2 10
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.038038628660142411 0.10269814218793599 0.22534844279289243 -0.21093816518783567 -0.04318924918770789
+leaf_weight=5.0000000000000009 6.9999999999999982 4 5 4
+leaf_count=5 7 4 5 4
+internal_value=0.00810559 0.147298 -0.10126 -0.136383
+internal_weight=25 11 14 9
+internal_count=25 11 14 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=24
+num_leaves=6
+num_cat=0
+split_feature=7 25 0 11 4
+split_gain=144.15 33.8287 15.5966 11.8087 6.02652
+threshold=20.10000038146973 2.5000000000000004 14.500000000000002 25.60000038146973 10.750000000000002
+decision_type=2 2 2 2 2
+left_child=1 -1 4 -2 -3
+right_child=3 2 -4 -5 -6
+leaf_value=0.027004780080169412 0.11261355678240459 -0.21149568185210224 -0.04717797376215456 0.22352228760719295 -0.12470193803310392
+leaf_weight=5.0000000000000044 5.9999999999999982 4 4.0000000000000009 4 4
+leaf_count=5 6 4 4 4 4
+internal_value=0.00634415 -0.0822634 -0.127792 0.156977 -0.168099
+internal_weight=27 17 12 10 8
+internal_count=27 17 12 10 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=25
+num_leaves=5
+num_cat=0
+split_feature=0 8 0 17
+split_gain=148.21 21.9847 15.6821 33.9071
+threshold=19.000000000000004 24.450000762939457 14.500000000000002 2.4500000476837163
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.03928348690271373 0.05030433788895608 0.18757798671722412 -0.02865193784236908 -0.21558304363667655
+leaf_weight=5.0000000000000053 4.9999999999999982 7 4.9999999999999991 6
+leaf_count=5 5 7 5 6
+internal_value=-0.00245028 0.130381 -0.102073 -0.135447
+internal_weight=28 12 16 11
+internal_count=28 12 16 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=26
+num_leaves=5
+num_cat=0
+split_feature=0 8 25 4
+split_gain=101.463 12.2381 10.6224 5.71198
+threshold=19.000000000000004 24.450000762939457 2.5000000000000004 12.500000000000002
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.033866082131862631 0.044497444108128559 0.15740439295768738 -0.16091174602508543 -0.085334072113037104
+leaf_weight=5.0000000000000009 3.9999999999999982 6 5 5
+leaf_count=5 4 6 5 5
+internal_value=-0.0111257 0.112242 -0.0933706 -0.123123
+internal_weight=25 10 15 10
+internal_count=25 10 15 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=27
+num_leaves=5
+num_cat=0
+split_feature=8 25 1 8
+split_gain=107.147 11.5527 12.8558 6.09309
+threshold=18.500000000000004 2.5000000000000004 14.000000000000002 24.450000762939457
+decision_type=2 2 8 2
+left_child=1 -1 -3 -2
+right_child=3 2 -4 -5
+leaf_value=-0.032172779291868202 0.064065082073211682 -0.055823281407356262 -0.17154486353198684 0.13633297596658978
+leaf_weight=5.0000000000000009 4.9999999999999982 3.9999999999999991 6.0000000000000009 7
+leaf_count=5 5 4 6 7
+internal_value=-0.00513963 -0.0942284 -0.125256 0.106221
+internal_weight=27 15 10 12
+internal_count=27 15 10 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=28
+num_leaves=5
+num_cat=0
+split_feature=11 11 8 7
+split_gain=95.4773 9.62276 8.25341 18.9375
+threshold=16.500000000000004 25.60000038146973 12.150000095367433 16.19999980926514
+decision_type=2 2 2 2
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.11294722110033029 0.091064527205058532 0.18828040659427639 0.027631362676620488 -0.11832991428673266
+leaf_weight=6.0000000000000027 6.9999999999999982 4 4.9999999999999982 4
+leaf_count=6 7 4 5 4
+internal_value=0.014528 0.126416 -0.0675231 -0.0372403
+internal_weight=26 11 15 9
+internal_count=26 11 15 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=29
+num_leaves=5
+num_cat=0
+split_feature=0 7 8 7
+split_gain=97.4171 16.9456 15.7058 15.4383
+threshold=19.000000000000004 16.19999980926514 24.450000762939457 12.200000286102297
+decision_type=2 2 2 2
+left_child=1 3 -2 -1
+right_child=2 -3 -4 -5
+leaf_value=-0.1125903564194837 0.042257812693715105 -0.17471121549606325 0.16224527359008789 0.00083466370900471998
+leaf_weight=6.0000000000000053 4.9999999999999982 3.9999999999999991 6 6
+leaf_count=6 5 4 6 6
+internal_value=-0.00683772 -0.0855862 0.107706 -0.0558778
+internal_weight=27 16 11 12
+internal_count=27 16 11 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=30
+num_leaves=5
+num_cat=0
+split_feature=9 8 10 17
+split_gain=62.2442 6.93422 5.98096 6.8285
+threshold=21.60000038146973 25.250000000000004 15.949999809265138 2.7500000000000004
+decision_type=2 2 2 2
+left_child=2 -2 3 -1
+right_child=1 -3 -4 -5
+leaf_value=-0.092129348005567199 0.082430855631828337 0.17075412273406979 -0.0032282443451029917 -0.010235727019608018
+leaf_weight=7.0000000000000053 4.9999999999999982 4 6.9999999999999991 4
+leaf_count=7 5 4 7 4
+internal_value=0.0143231 0.121686 -0.0393581 -0.0623498
+internal_weight=27 9 18 11
+internal_count=27 9 18 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=31
+num_leaves=5
+num_cat=0
+split_feature=0 7 11 11
+split_gain=33.8001 20.4906 7.17044 1.20202
+threshold=19.000000000000004 16.19999980926514 24.450000762939457 11.84999990463257
+decision_type=2 2 2 2
+left_child=1 3 -2 -1
+right_child=2 -3 -4 -5
+leaf_value=0.027611190825700745 0.046303890459239494 -0.1207993793487549 0.13272850215435025 -0.011151227727532385
+leaf_weight=4.0000000000000018 5.9999999999999982 4.9999999999999991 4 4
+leaf_count=4 6 5 4 4
+internal_value=0.0117644 -0.0413967 0.0808737 0.00822998
+internal_weight=23 13 10 8
+internal_count=23 13 10 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=32
+num_leaves=5
+num_cat=0
+split_feature=10 8 10 7
+split_gain=139.609 16.2024 2.99663 1.61411
+threshold=15.949999809265138 24.450000762939457 26.050000190734867 17.099999427795414
+decision_type=2 2 2 2
+left_child=-1 3 -3 -2
+right_child=1 2 -4 -5
+leaf_value=-0.19305681660771362 0.0018889637663960454 0.0943771332502365 0.15557999610900874 0.046807073801755895
+leaf_weight=7.0000000000000027 4 4 4 4
+leaf_count=7 4 4 4 4
+internal_value=-0.00681674 0.0746633 0.124979 0.024348
+internal_weight=23 16 8 8
+internal_count=23 16 8 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=33
+num_leaves=6
+num_cat=0
+split_feature=11 11 25 1 17
+split_gain=68.2268 6.95329 6.60298 15.0135 2.5217
+threshold=16.500000000000004 25.60000038146973 2.5000000000000004 1.0000000180025095e-35 2.0499999523162846
+decision_type=2 2 2 10 2
+left_child=2 4 -1 -4 -2
+right_child=1 -3 3 -5 -6
+leaf_value=-0.019063188433647151 0.095134383440017734 0.14780099391937254 -0.15070011615753173 -0.028170542903244489 0.03899061046540736
+leaf_weight=5.0000000000000009 3.9999999999999982 4 5 5 4
+leaf_count=5 4 4 5 5 4
+internal_value=0.0051124 0.0939753 -0.0659779 -0.0894353 0.0670625
+internal_weight=27 12 15 10 8
+internal_count=27 12 15 10 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=34
+num_leaves=5
+num_cat=0
+split_feature=8 17 8 4
+split_gain=64.4868 7.99851 5.97112 1.72567
+threshold=18.500000000000004 2.4500000476837163 24.450000762939457 10.750000000000002
+decision_type=2 2 2 2
+left_child=1 -1 -2 -3
+right_child=2 3 -4 -5
+leaf_value=-0.030552486436707618 0.049446627795696273 -0.12696040404262018 0.12342990686496098 -0.080515943840146043
+leaf_weight=7.0000000000000027 4.9999999999999982 4 6 4
+leaf_count=7 5 4 6 4
+internal_value=-0.00215232 -0.0695849 0.0898011 -0.103738
+internal_weight=26 15 11 8
+internal_count=26 15 11 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=35
+num_leaves=5
+num_cat=0
+split_feature=7 7 0 8
+split_gain=45.7006 9.51823 12.8164 8.09556
+threshold=20.10000038146973 16.19999980926514 12.000000000000002 25.250000000000004
+decision_type=2 2 2 2
+left_child=1 2 -1 -2
+right_child=3 -3 -4 -5
+leaf_value=-0.080500855190413279 0.033643957972526548 -0.11893940009176732 0.024311005473136901 0.13423945009708402
+leaf_weight=7.0000000000000053 4 4.9999999999999991 5 4
+leaf_count=7 4 5 5 4
+internal_value=-0.0146046 -0.0609793 -0.0368292 0.0839417
+internal_weight=25 17 12 8
+internal_count=25 17 12 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=36
+num_leaves=4
+num_cat=0
+split_feature=0 25 11
+split_gain=61.0685 17.1927 2.87348
+threshold=19.000000000000004 2.5000000000000004 24.450000762939457
+decision_type=2 2 2
+left_child=1 -1 -2
+right_child=2 -3 -4
+leaf_value=-0.023952146060764776 0.043691192865371715 -0.15777686238288879 0.097296019792556754
+leaf_weight=4.0000000000000018 4.9999999999999982 6 5
+leaf_count=4 5 6 5
+internal_value=-0.0168767 -0.104247 0.0704936
+internal_weight=20 10 10
+internal_count=20 10 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=37
+num_leaves=5
+num_cat=0
+split_feature=9 7 0 9
+split_gain=17.8292 9.12726 2.14608 0.37796
+threshold=21.60000038146973 16.19999980926514 12.000000000000002 25.150000572204593
+decision_type=2 2 2 2
+left_child=1 2 -1 -2
+right_child=3 -3 -4 -5
+leaf_value=-0.0086874480048815313 0.059223059564828866 -0.064216595781700955 0.03859366923570632 0.080958966165781007
+leaf_weight=6.0000000000000053 4 6.9999999999999991 4 4
+leaf_count=6 4 7 4 4
+internal_value=0.00853848 -0.0204274 0.010225 0.070091
+internal_weight=25 17 10 8
+internal_count=25 17 10 8
+is_linear=0
+shrinkage=0.05
+
+
+Tree=38
+num_leaves=5
+num_cat=0
+split_feature=0 0 17 8
+split_gain=42.1852 8.11334 22.3295 3.14193
+threshold=19.000000000000004 14.500000000000002 2.4500000476837163 25.250000000000004
+decision_type=2 2 2 2
+left_child=1 2 -1 -2
+right_child=3 -3 -4 -5
+leaf_value=-0.0026285094022750841 0.059161599576473246 0.00066261783242225645 -0.15205909729003905 0.11861472725868223
+leaf_weight=5.0000000000000018 4.9999999999999982 4.9999999999999991 5 4
+leaf_count=5 5 5 5 4
+internal_value=5.91511e-06 -0.0513417 -0.0773438 0.0855852
+internal_weight=24 15 10 9
+internal_count=24 15 10 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=39
+num_leaves=5
+num_cat=0
+split_feature=8 0 7 17
+split_gain=42.3517 9.40166 11.1999 0.874838
+threshold=18.500000000000004 12.000000000000002 16.19999980926514 2.0499999523162846
+decision_type=2 2 2 2
+left_child=1 -1 -3 -2
+right_child=3 2 -4 -5
+leaf_value=-0.13055932044982901 0.075182829797267939 0.016001777164638041 -0.1023194123059511 0.04587044503007616
+leaf_weight=5.0000000000000027 3.9999999999999982 4 4 7
+leaf_count=5 4 4 4 7
+internal_value=-0.0156768 -0.0767744 -0.0431588 0.0565295
+internal_weight=24 13 8 11
+internal_count=24 13 8 11
+is_linear=0
+shrinkage=0.05
+
+
+Tree=40
+num_leaves=5
+num_cat=0
+split_feature=10 11 17 11
+split_gain=30.5208 9.56514 5.99462 2.55558
+threshold=15.949999809265138 25.60000038146973 2.4500000476837163 16.500000000000004
+decision_type=2 2 2 2
+left_child=2 3 -1 -2
+right_child=1 -3 -4 -5
+leaf_value=-0.020241806656122199 -0.0061912831664085403 0.11039046943187714 -0.091923368615763515 0.040611458463328226
+leaf_weight=5.0000000000000018 4.9999999999999982 3.9999999999999991 7 7
+leaf_count=5 5 4 7 7
+internal_value=-0.00177811 0.0434304 -0.0620561 0.0211103
+internal_weight=28 16 12 12
+internal_count=28 16 12 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=41
+num_leaves=5
+num_cat=0
+split_feature=0 7 8 8
+split_gain=30.9277 6.6852 10.0167 4.88011
+threshold=19.000000000000004 16.19999980926514 12.150000095367433 24.450000762939457
+decision_type=2 2 2 2
+left_child=1 2 -1 -2
+right_child=3 -3 -4 -5
+leaf_value=-0.066878687590360572 0.02567629516124726 -0.090010862350463877 0.024484723185499505 0.090352048618452876
+leaf_weight=6.0000000000000053 4.9999999999999982 4.9999999999999991 6 7
+leaf_count=6 5 5 6 7
+internal_value=0.00194578 -0.0414364 -0.021197 0.0634038
+internal_weight=29 17 12 12
+internal_count=29 17 12 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=42
+num_leaves=5
+num_cat=0
+split_feature=8 11 25 1
+split_gain=28.5355 6.34384 5.30562 10.4336
+threshold=18.500000000000004 24.450000762939457 2.5000000000000004 14.000000000000002
+decision_type=2 2 2 8
+left_child=2 -2 -1 -4
+right_child=1 -3 3 -5
+leaf_value=-0.0040137021243572225 0.019529772975615096 0.086844800199781136 -0.0045439243316650396 -0.10879524437089759
+leaf_weight=5.0000000000000009 6.9999999999999982 7 3.9999999999999991 6.0000000000000009
+leaf_count=5 7 7 4 6
+internal_value=0.00184849 0.0531873 -0.0460677 -0.0670947
+internal_weight=29 14 15 10
+internal_count=29 14 15 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=43
+num_leaves=6
+num_cat=0
+split_feature=0 17 8 11 17
+split_gain=26.6717 12.8972 8.65111 2.70049 0.773571
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 25.60000038146973 2.0499999523162846
+decision_type=2 2 2 2 2
+left_child=1 -1 -2 4 -4
+right_child=2 -3 3 -5 -6
+leaf_value=0.0060157436132431 -0.022258963063359241 -0.099125871488026207 0.063024595379829421 0.096011102199554443 0.033524291813373561
+leaf_weight=5.0000000000000018 4.0000000000000027 7 3.9999999999999982 3.9999999999999991 5
+leaf_count=5 4 7 4 4 5
+internal_value=0.00175607 -0.0553169 0.0420428 0.061828 0.0466355
+internal_weight=29 12 17 13 9
+internal_count=29 12 17 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=44
+num_leaves=6
+num_cat=0
+split_feature=0 17 8 11 0
+split_gain=24.0712 11.6397 7.80763 2.4372 0.718832
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 25.60000038146973 27.149999618530277
+decision_type=2 2 2 2 2
+left_child=1 -1 -2 4 -4
+right_child=2 -3 3 -5 -6
+leaf_value=0.0057149547338485691 -0.021146014332771284 -0.094169578381947103 0.056942607164382944 0.091210547089576724 0.028505202569067474
+leaf_weight=5.0000000000000018 4.0000000000000027 7 4.9999999999999982 3.9999999999999991 4
+leaf_count=5 4 7 5 4 4
+internal_value=0.00166827 -0.052551 0.0399407 0.0587366 0.0443038
+internal_weight=29 12 17 13 9
+internal_count=29 12 17 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=45
+num_leaves=5
+num_cat=0
+split_feature=0 17 8 11
+split_gain=21.7243 10.5049 7.04638 2.23565
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 24.450000762939457
+decision_type=2 2 2 2
+left_child=1 -1 -2 -4
+right_child=2 -3 3 -5
+leaf_value=0.0054292047023773169 -0.020088713057339176 -0.089461099249976014 0.033403639619549119 0.074996486306190477
+leaf_weight=5.0000000000000018 4.0000000000000027 7 5.9999999999999982 7
+leaf_count=5 4 7 6 7
+internal_value=0.00158485 -0.0499235 0.0379437 0.0557998
+internal_weight=29 12 17 13
+internal_count=29 12 17 13
+is_linear=0
+shrinkage=0.05
+
+
+Tree=46
+num_leaves=6
+num_cat=0
+split_feature=0 17 8 11 17
+split_gain=19.6062 9.48064 6.35936 2.06483 0.674355
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 25.60000038146973 2.0499999523162846
+decision_type=2 2 2 2 2
+left_child=1 -1 -2 4 -4
+right_child=2 -3 3 -5 -6
+leaf_value=0.0051577466726303077 -0.019084277842193825 -0.08498804313795906 0.055027168244123473 0.082900194823741918 0.027483583539724344
+leaf_weight=5.0000000000000018 4.0000000000000027 7 3.9999999999999982 3.9999999999999991 5
+leaf_count=5 4 7 4 4 5
+internal_value=0.00150561 -0.0474273 0.0360465 0.0530098 0.0397252
+internal_weight=29 12 17 13 9
+internal_count=29 12 17 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=47
+num_leaves=5
+num_cat=0
+split_feature=0 17 8 11
+split_gain=17.6946 8.55628 5.73932 1.86565
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 24.450000762939457
+decision_type=2 2 2 2
+left_child=1 -1 -2 -4
+right_child=2 -3 3 -5
+leaf_value=0.0048998612165451032 -0.01813006475567816 -0.080738642386027737 0.029900218360126025 0.067895670873778208
+leaf_weight=5.0000000000000018 4.0000000000000027 7 5.9999999999999982 7
+leaf_count=5 4 7 6 7
+internal_value=0.00143033 -0.0450559 0.0342442 0.0503593
+internal_weight=29 12 17 13
+internal_count=29 12 17 13
+is_linear=0
+shrinkage=0.05
+
+
+Tree=48
+num_leaves=7
+num_cat=0
+split_feature=0 8 25 4 11 0
+split_gain=15.9693 5.17974 4.82807 3.49022 1.7502 0.626879
+threshold=14.500000000000002 18.500000000000004 2.5000000000000004 10.750000000000002 25.60000038146973 27.149999618530277
+decision_type=2 2 2 2 2 2
+left_child=2 -2 -1 -4 5 -3
+right_child=1 4 3 -5 -6 -7
+leaf_value=0.0020488075911998739 -0.017223561555147156 0.047413467168807996 -0.098254744708538039 -0.032203470170497892 0.075360403954982766 0.020857126638293262
+leaf_weight=4.0000000000000009 4.0000000000000027 4.9999999999999982 4 4 3.9999999999999991 4
+leaf_count=4 4 5 4 4 4 4
+internal_value=0.00135881 0.032532 -0.0428031 -0.0652291 0.0478413 0.0356106
+internal_weight=29 17 12 8 13 9
+internal_count=29 17 12 8 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=49
+num_leaves=6
+num_cat=0
+split_feature=0 17 8 11 17
+split_gain=14.4123 7.43288 4.67472 1.57956 0.58844
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 25.60000038146973 2.0499999523162846
+decision_type=2 2 2 2 2
+left_child=1 -1 -2 4 -4
+right_child=2 -3 3 -5 -6
+leaf_value=0.0058979833126068084 -0.016362382937222707 -0.073920809371130811 0.048124146461486828 0.071592382341623309 0.02239489287137985
+leaf_weight=5.0000000000000018 4.0000000000000027 7 3.9999999999999982 3.9999999999999991 5
+leaf_count=5 4 7 4 4 5
+internal_value=0.00129087 -0.040663 0.0309054 0.0454493 0.0338301
+internal_weight=29 12 17 13 9
+internal_count=29 12 17 13 9
+is_linear=0
+shrinkage=0.05
+
+
+Tree=50
+num_leaves=5
+num_cat=0
+split_feature=0 17 8 11
+split_gain=13.0071 6.70817 4.21893 1.44844
+threshold=14.500000000000002 2.4500000476837163 18.500000000000004 24.450000762939457
+decision_type=2 2 2 2
+left_child=1 -1 -2 -4
+right_child=2 -3 3 -5
+leaf_value=0.0056030857563018769 -0.015544263552874313 -0.070224771329334787 0.025149877386866144 0.058628469705581658
+leaf_weight=5.0000000000000018 4.0000000000000027 7 5.9999999999999982 7
+leaf_count=5 4 7 6 7
+internal_value=0.00122633 -0.0386298 0.0293601 0.0431768
+internal_weight=29 12 17 13
+internal_count=29 12 17 13
+is_linear=0
+shrinkage=0.05
+
+
+Tree=51
+num_leaves=6
+num_cat=0
+split_feature=10 11 1 7 1
+split_gain=11.7643 3.67241 2.87184 11.2515 0.582723
+threshold=15.949999809265138 24.450000762939457 25.000000000000004 12.200000286102297 1.0000000180025095e-35
+decision_type=2 2 8 2 10
+left_child=2 4 -1 -4 -2
+right_child=1 -3 3 -5 -6
+leaf_value=-0.0021472714841365803 0.018332585987324509 0.055697047923292435 -0.11333190500736234 0.0052616555942222466 -0.0063048541545867908
+leaf_weight=4.0000000000000009 6.0000000000000018 6.9999999999999991 4 4 4
+leaf_count=4 6 7 4 4 4
+internal_value=0.00116501 0.0279209 -0.0367392 -0.0540351 0.00847761
+internal_weight=29 17 12 8 10
+internal_count=29 17 12 8 10
+is_linear=0
+shrinkage=0.05
+
+
+Tree=52
+num_leaves=6
+num_cat=0
+split_feature=0 7 4 17 11
+split_gain=10.7969 7.03751 8.34685 4.42031 2.13665
+threshold=19.000000000000004 16.19999980926514 8.7500000000000018 2.7500000000000004 24.450000762939457
+decision_type=2 2 2 2 2
+left_child=1 2 -1 -4 -2
+right_child=4 -3 3 -5 -6
+leaf_value=-0.062733021378517037 0.016320943273603918 -0.07436351835727692 -0.011439524823799728 0.062893457710742937 0.058517393966515854
+leaf_weight=4.0000000000000062 5.9999999999999982 4.9999999999999991 4 4 6
+leaf_count=4 6 5 4 4 6
+internal_value=0.00110676 -0.0245255 -0.0037597 0.025727 0.0374192
+internal_weight=29 17 12 8 12
+internal_count=29 17 12 8 12
+is_linear=0
+shrinkage=0.05
+
+
+Tree=53
+num_leaves=7
+num_cat=0
+split_feature=10 8 1 7 25 7
+split_gain=10.114 2.78729 2.48769 10.1288 1.37513 0.370549
+threshold=15.949999809265138 24.450000762939457 25.000000000000004 12.200000286102297 2.5000000000000004 17.099999427795414
+decision_type=2 2 8 2 2 2
+left_child=2 5 -1 -4 -3 -2
+right_child=1 4 3 -5 -6 -7
+leaf_value=-0.0018985368311405177 0.018114815652370442 0.026604005694389338 -0.1064521662890911 0.0060691058868542307 0.068063744157552716 -0.0023025212436914438
+leaf_weight=4.0000000000000009 4.0000000000000018 4 4 4 4 5
+leaf_count=4 4 4 4 4 4 5
+internal_value=0.00105142 0.0258599 -0.0340939 -0.0501915 0.0473339 0.00677185
+internal_weight=29 17 12 8 8 9
+internal_count=29 17 12 8 8 9
+is_linear=0
+shrinkage=0.05
+
+
+end of trees
+
+feature_importances:
+actual_high_lag_1=52
+ecmwf=46
+open_meteo=28
+jma=27
+model_spread=24
+weekday=20
+gem=11
+actual_high_mean_7=10
+actual_high_lag_2=9
+gfs=4
+
+parameters:
+[boosting: gbdt]
+[objective: regression]
+[metric: l1]
+[tree_learner: serial]
+[device_type: cpu]
+[data_sample_strategy: bagging]
+[data: ]
+[valid: ]
+[num_iterations: 54]
+[learning_rate: 0.05]
+[num_leaves: 15]
+[num_threads: 0]
+[seed: 42]
+[deterministic: 0]
+[force_col_wise: 0]
+[force_row_wise: 0]
+[histogram_pool_size: -1]
+[max_depth: -1]
+[min_data_in_leaf: 4]
+[min_sum_hessian_in_leaf: 0.001]
+[bagging_fraction: 0.9]
+[pos_bagging_fraction: 1]
+[neg_bagging_fraction: 1]
+[bagging_freq: 1]
+[bagging_seed: 400]
+[bagging_by_query: 0]
+[feature_fraction: 0.9]
+[feature_fraction_bynode: 1]
+[feature_fraction_seed: 30056]
+[extra_trees: 0]
+[extra_seed: 12879]
+[early_stopping_round: 0]
+[early_stopping_min_delta: 0]
+[first_metric_only: 0]
+[max_delta_step: 0]
+[lambda_l1: 0]
+[lambda_l2: 0]
+[linear_lambda: 0]
+[min_gain_to_split: 0]
+[drop_rate: 0.1]
+[max_drop: 50]
+[skip_drop: 0.5]
+[xgboost_dart_mode: 0]
+[uniform_drop: 0]
+[drop_seed: 17869]
+[top_rate: 0.2]
+[other_rate: 0.1]
+[min_data_per_group: 100]
+[max_cat_threshold: 32]
+[cat_l2: 10]
+[cat_smooth: 10]
+[max_cat_to_onehot: 4]
+[top_k: 20]
+[monotone_constraints: ]
+[monotone_constraints_method: basic]
+[monotone_penalty: 0]
+[feature_contri: ]
+[forcedsplits_filename: ]
+[refit_decay_rate: 0.9]
+[cegb_tradeoff: 1]
+[cegb_penalty_split: 0]
+[cegb_penalty_feature_lazy: ]
+[cegb_penalty_feature_coupled: ]
+[path_smooth: 0]
+[interaction_constraints: ]
+[verbosity: -1]
+[saved_feature_importance_type: 0]
+[use_quantized_grad: 0]
+[num_grad_quant_bins: 4]
+[quant_train_renew_leaf: 0]
+[stochastic_rounding: 1]
+[linear_tree: 0]
+[max_bin: 255]
+[max_bin_by_feature: ]
+[min_data_in_bin: 3]
+[bin_construct_sample_cnt: 200000]
+[data_random_seed: 175]
+[is_enable_sparse: 1]
+[enable_bundle: 1]
+[use_missing: 1]
+[zero_as_missing: 0]
+[feature_pre_filter: 1]
+[pre_partition: 0]
+[two_round: 0]
+[header: 0]
+[label_column: ]
+[weight_column: ]
+[group_column: ]
+[ignore_column: ]
+[categorical_feature: ]
+[forcedbins_filename: ]
+[precise_float_parser: 0]
+[parser_config_file: ]
+[objective_seed: 16083]
+[num_class: 1]
+[is_unbalance: 0]
+[scale_pos_weight: 1]
+[sigmoid: 1]
+[boost_from_average: 1]
+[reg_sqrt: 0]
+[alpha: 0.9]
+[fair_c: 1]
+[poisson_max_delta_step: 0.7]
+[tweedie_variance_power: 1.5]
+[lambdarank_truncation_level: 30]
+[lambdarank_norm: 1]
+[label_gain: ]
+[lambdarank_position_bias_regularization: 0]
+[eval_at: ]
+[multi_error_top_k: 1]
+[auc_mu_weights: ]
+[num_machines: 1]
+[local_listen_port: 12400]
+[time_out: 120]
+[machine_list_filename: ]
+[machines: ]
+[gpu_platform_id: -1]
+[gpu_device_id: -1]
+[gpu_use_dp: 0]
+[num_gpu: 1]
+
+end of parameters
+
+pandas_categorical:null
diff --git a/artifacts/models/lgbm_daily_high_schema.json b/artifacts/models/lgbm_daily_high_schema.json
new file mode 100644
index 00000000..0349e472
--- /dev/null
+++ b/artifacts/models/lgbm_daily_high_schema.json
@@ -0,0 +1,66 @@
+{
+ "model_type": "LightGBMRegressor",
+ "target": "actual_high",
+ "horizon": "D0",
+ "feature_names": [
+ "actual_high_lag_1",
+ "actual_high_lag_2",
+ "actual_high_lag_3",
+ "actual_high_lag_7",
+ "actual_high_mean_7",
+ "actual_high_mean_14",
+ "actual_high_trend_3",
+ "open_meteo",
+ "ecmwf",
+ "gfs",
+ "gem",
+ "jma",
+ "icon",
+ "mgm",
+ "nws",
+ "deb_prediction",
+ "model_median",
+ "model_spread",
+ "current_temp",
+ "max_so_far",
+ "humidity",
+ "wind_speed_kt",
+ "visibility_mi",
+ "local_hour",
+ "month",
+ "weekday",
+ "peak_status_code"
+ ],
+ "base_model_columns": [
+ "open_meteo",
+ "ecmwf",
+ "gfs",
+ "gem",
+ "jma",
+ "icon",
+ "mgm",
+ "nws"
+ ],
+ "model_path": "artifacts\\models\\lgbm_daily_high.txt",
+ "sample_count": 29,
+ "train_count": 17,
+ "validation_count": 12,
+ "metrics": {
+ "validation": {
+ "sample_count": 12,
+ "lgbm_mae": 2.975,
+ "deb_mae": 2.267,
+ "best_single_mae": 1.167,
+ "median_mae": 2.483
+ },
+ "full_sample": {
+ "sample_count": 29,
+ "lgbm_mae": 1.442,
+ "deb_mae": 3.448,
+ "best_single_mae": 2.486,
+ "median_mae": 3.628
+ }
+ },
+ "generated_at": "2026-03-29T14:57:35.832414Z",
+ "trained_at": "2026-03-29T14:57:35.832414Z"
+}
\ No newline at end of file
diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl
index 6c5a7304..48bba991 100644
--- a/data/probability_training_snapshots.jsonl
+++ b/data/probability_training_snapshots.jsonl
@@ -121,3 +121,5 @@
{"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514}
{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
{"city": "shenzhen", "timestamp": "2026-03-25T10:02:35+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.15911458333333342, "deb_prediction": 28.2, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.5, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.5, "GEM": 30.7, "JMA": 26.2}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "shanghai", "timestamp": "2026-03-29T15:00:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.23736458333333335, "deb_prediction": 18.4, "ensemble": {"p10": 16.1, "median": 16.5, "p90": 16.9}, "multi_model": {"Open-Meteo": 17.0, "ECMWF": 19.2, "GFS": 19.1, "ICON": 17.0, "GEM": 17.6, "JMA": 15.8}, "max_so_far": 18.0, "observation": {"current_temp": 14.0, "humidity": null, "wind_speed_kt": 4.0, "visibility_mi": 3.11, "local_hour": 23.25}, "peak_status": "past", "prob_snapshot": [{"v": 18, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
+{"city": "ankara", "timestamp": "2026-03-29T15:01:00.000Z", "date": "2026-03-29", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.25176666666666664, "deb_prediction": 9.7, "ensemble": {"p10": 9.2, "median": 9.5, "p90": 10.2}, "multi_model": {"Open-Meteo": 9.2, "ECMWF": 9.5, "GFS": 10.1, "ICON": 9.2, "GEM": 11.0, "JMA": 10.0}, "max_so_far": 10.0, "observation": {"current_temp": 7.0, "humidity": null, "wind_speed_kt": 12.0, "visibility_mi": null, "local_hour": 18.25}, "peak_status": "past", "prob_snapshot": [{"v": 10, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
diff --git a/docs/LGBM_DAILY_HIGH_ZH.md b/docs/LGBM_DAILY_HIGH_ZH.md
new file mode 100644
index 00000000..dd68b4cc
--- /dev/null
+++ b/docs/LGBM_DAILY_HIGH_ZH.md
@@ -0,0 +1,310 @@
+# LightGBM 日最高温模型(中文)
+
+## 1. 目标
+
+这套 `LightGBM` 模型是给 PolyWeather 增加一个轻量级的统计学习预测源。
+
+它的定位不是替代:
+
+- `DEB`
+- `EMOS`
+- `ECMWF / GFS / GEM / JMA / ICON / Open-Meteo / MGM / NWS`
+
+而是作为一个新的点预测源:
+
+`现有模型 + 观测特征 -> LGBM -> 并入 current_forecasts -> DEB -> EMOS`
+
+第一版只做:
+
+- `D0` 当日最高温预测
+
+不做:
+
+- `D1-D3`
+- 小时级曲线
+- 概率分布
+- 独立结算源
+
+## 2. 适用场景
+
+这条链路是为低资源 VPS 准备的。
+
+当前项目线上环境只有 `2GB RAM` 时,不适合引入 `TimesFM` 这类大模型,但适合用 `LightGBM` 做轻量推理。
+
+当前方案是:
+
+1. 训练离线完成
+2. 训练产物直接提交到仓库
+3. VPS 线上只加载模型文件并推理
+4. VPS 不训练,不起额外服务
+
+## 3. 文件结构
+
+核心文件如下:
+
+- 运行时推理:
+ - [src/models/lgbm_daily_high.py](/E:/web/PolyWeather/src/models/lgbm_daily_high.py)
+- 特征构建:
+ - [src/models/lgbm_features.py](/E:/web/PolyWeather/src/models/lgbm_features.py)
+- 训练脚本:
+ - [scripts/train_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/train_lgbm_daily_high.py)
+- 训练报告脚本:
+ - [scripts/report_lgbm_daily_high.py](/E:/web/PolyWeather/scripts/report_lgbm_daily_high.py)
+- 模型文件:
+ - [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
+- 模型 schema / 指标:
+ - [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
+
+接入链路位置:
+
+- Web API 聚合:
+ - [web/analysis_service.py](/E:/web/PolyWeather/web/analysis_service.py)
+- 共享趋势引擎:
+ - [src/analysis/trend_engine.py](/E:/web/PolyWeather/src/analysis/trend_engine.py)
+
+## 4. 特征说明
+
+第一版特征固定为以下几组。
+
+### 4.1 历史日高温特征
+
+- `actual_high_lag_1`
+- `actual_high_lag_2`
+- `actual_high_lag_3`
+- `actual_high_lag_7`
+- `actual_high_mean_7`
+- `actual_high_mean_14`
+- `actual_high_trend_3`
+
+### 4.2 当天模型特征
+
+- `Open-Meteo`
+- `ECMWF`
+- `GFS`
+- `GEM`
+- `JMA`
+- `ICON`
+- `MGM`
+- `NWS`
+- `deb_prediction`
+- `model_median`
+- `model_spread`
+
+### 4.3 当前观测特征
+
+- `current_temp`
+- `max_so_far`
+- `humidity`
+- `wind_speed_kt`
+- `visibility_mi`
+
+### 4.4 时间与状态特征
+
+- `local_hour`
+- `month`
+- `weekday`
+- `peak_status_code`
+
+其中:
+
+- `before = 0`
+- `in_window = 1`
+- `past = 2`
+
+## 5. 训练数据来源
+
+训练数据主要来自两份运行时历史文件:
+
+- [data/daily_records.json](/E:/web/PolyWeather/data/daily_records.json)
+- [data/probability_training_snapshots.jsonl](/E:/web/PolyWeather/data/probability_training_snapshots.jsonl)
+
+作用分工:
+
+- `daily_records.json`
+ - 提供 `actual_high`
+ - 提供当天各模型 forecast
+ - 提供历史 `deb_prediction`
+
+- `probability_training_snapshots.jsonl`
+ - 提供 `max_so_far`
+ - 提供 `peak_status`
+ - 提供观测特征快照
+
+为后续重训,概率快照归档现在还会额外写入:
+
+- `current_temp`
+- `humidity`
+- `wind_speed_kt`
+- `visibility_mi`
+- `local_hour`
+
+对应代码:
+
+- [src/analysis/probability_snapshot_archive.py](/E:/web/PolyWeather/src/analysis/probability_snapshot_archive.py)
+
+## 6. 训练流程
+
+训练脚本:
+
+```bash
+./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
+```
+
+训练流程如下:
+
+1. 从历史文件构造监督样本
+2. 目标值固定为 `actual_high`
+3. 按日期做简单的时间顺序切分
+4. 最后约 20% 做验证集
+5. 先训练并评估验证集
+6. 再用全量样本训练最终模型
+7. 输出模型文件和 schema 文件
+
+输出产物:
+
+- [artifacts/models/lgbm_daily_high.txt](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high.txt)
+- [artifacts/models/lgbm_daily_high_schema.json](/E:/web/PolyWeather/artifacts/models/lgbm_daily_high_schema.json)
+
+## 7. 如何看训练结果
+
+查看训练报告:
+
+```bash
+./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
+```
+
+这个脚本会读取 schema,并打印:
+
+- `Sample Count`
+- `Train Count`
+- `Valid Count`
+- `LGBM MAE`
+- `DEB MAE`
+- `Best Single MAE`
+- `Median MAE`
+- `Winner`
+
+当前这版训练结果是:
+
+- `sample_count = 29`
+- `validation_count = 12`
+- `validation.lgbm_mae = 2.975`
+- `validation.deb_mae = 2.267`
+- `validation.best_single_mae = 1.167`
+
+这说明:
+
+- 当前 `LGBM` 链路已经可用
+- 但现阶段验证集表现还没有超过 `DEB`
+- 所以默认配置仍建议保持关闭
+
+## 8. 线上运行逻辑
+
+运行时推理逻辑不是“直接替代 DEB”,而是:
+
+1. 先收集现有模型 forecast
+2. 先算一版基线 `DEB`
+3. 把这版 `DEB` 当作 `LGBM` 的一个输入特征
+4. 输出 `LGBM` 点预测
+5. 把 `LGBM` 注入 `current_forecasts`
+6. 重新计算最终 `DEB`
+
+这样做的原因是:
+
+- `LGBM` 需要吃到 `deb_prediction` 特征
+- 但最终 `DEB` 又要把 `LGBM` 当成一个新的输入模型
+
+## 9. 环境变量
+
+示例配置见:
+
+- [.env.example](/E:/web/PolyWeather/.env.example)
+
+相关变量:
+
+```env
+POLYWEATHER_LGBM_ENABLED=false
+POLYWEATHER_LGBM_MODEL_PATH=/app/artifacts/models/lgbm_daily_high.txt
+POLYWEATHER_LGBM_SCHEMA_PATH=/app/artifacts/models/lgbm_daily_high_schema.json
+POLYWEATHER_LGBM_MIN_HISTORY_POINTS=3
+```
+
+说明:
+
+- `POLYWEATHER_LGBM_ENABLED`
+ - 是否启用运行时推理
+- `POLYWEATHER_LGBM_MODEL_PATH`
+ - 模型文件路径
+- `POLYWEATHER_LGBM_SCHEMA_PATH`
+ - schema 文件路径
+- `POLYWEATHER_LGBM_MIN_HISTORY_POINTS`
+ - 某城市最低历史样本门槛
+
+默认是 `3`,原因不是最理想,而是当前整体样本仍然偏少。
+
+如果门槛设太高,很多城市现在根本不会触发 `LGBM`。
+
+## 10. VPS 部署建议
+
+如果你的 VPS 只有 `2GB RAM`:
+
+- 可以跑这套 `LightGBM`
+- 不要在 VPS 上训练
+- 不要起额外模型服务
+
+推荐方式:
+
+1. 在本地或开发环境训练
+2. 提交模型产物
+3. VPS 拉代码
+4. 开启 `POLYWEATHER_LGBM_ENABLED=true`
+5. 重启主服务
+
+不推荐:
+
+- 在 VPS 上跑训练脚本
+- 把 `LightGBM` 当成长任务服务单独部署
+- 同时引入大模型推理
+
+## 11. 当前结论
+
+这条链路已经完成了:
+
+- 离线训练
+- 模型产物固化
+- 运行时懒加载
+- Web / 共享分析链路注入
+- 前端模型类型兼容
+
+但当前样本量仍偏少,所以建议运营策略是:
+
+1. 先继续积累历史 `actual_high`
+2. 继续积累概率快照观测字段
+3. 定期重训
+4. 只有当验证集 `MAE` 持续接近或优于 `DEB` 时,再考虑默认线上开启
+
+## 12. 常用命令
+
+### 训练
+
+```bash
+./venv/Scripts/python.exe scripts/train_lgbm_daily_high.py
+```
+
+### 查看训练报告
+
+```bash
+./venv/Scripts/python.exe scripts/report_lgbm_daily_high.py
+```
+
+### 本地测试
+
+```bash
+./venv/Scripts/python.exe -m pytest tests/test_lgbm_features.py tests/test_lgbm_daily_high.py
+```
+
+### 编译检查
+
+```bash
+./venv/Scripts/python.exe -m compileall src web scripts tests
+```
diff --git a/frontend/components/dashboard/CitySidebar.tsx b/frontend/components/dashboard/CitySidebar.tsx
index cfffb03b..9070215f 100644
--- a/frontend/components/dashboard/CitySidebar.tsx
+++ b/frontend/components/dashboard/CitySidebar.tsx
@@ -4,7 +4,7 @@ import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
-import { CityListItem } from "@/lib/dashboard-types";
+import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
@@ -50,7 +50,7 @@ function normalizeExpandedGroups(
export function CitySidebar() {
const store = useDashboardStore();
- const { t } = useI18n();
+ const { locale, t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
@@ -114,6 +114,16 @@ export function CitySidebar() {
} catch {}
}, [expandedGroups]);
+ const formatDeviationText = (monitor?: DeviationMonitor | null) => {
+ if (!monitor?.available) return "";
+ 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 "";
+ return trendLabel ? `${label} · ${trendLabel}` : label;
+ };
+
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
@@ -172,6 +182,9 @@ export function CitySidebar() {
temp: `${snapshot.current.temp}${tempSymbol}`,
})
: t("common.na");
+ const deviationText = formatDeviationText(
+ snapshot?.deviation_monitor,
+ );
const peakTempText =
detail?.current?.max_so_far != null &&
detail.current.max_temp_time
@@ -182,6 +195,11 @@ export function CitySidebar() {
: detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
: "";
+ const deviationDirection =
+ snapshot?.deviation_monitor?.direction || "normal";
+ const deviationSeverity =
+ snapshot?.deviation_monitor?.severity || "normal";
+ const secondaryText = deviationText || peakTempText;
return (
);
diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index ddacdf39..1022dae7 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -488,6 +488,30 @@
font-weight: 500;
}
+.root :global(.city-item .city-deviation-info) {
+ font-weight: 600;
+}
+
+.root :global(.city-item .city-deviation-cold) {
+ color: #38bdf8;
+}
+
+.root :global(.city-item .city-deviation-hot) {
+ color: #f59e0b;
+}
+
+.root :global(.city-item .city-deviation-normal) {
+ color: #22d3ee;
+}
+
+.root :global(.city-item .city-deviation-info.strong) {
+ text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
+}
+
+.root :global(.city-item .city-deviation-hot.strong) {
+ text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
+}
+
.root :global(.city-item .risk-dot) {
width: 10px;
height: 10px;
diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts
index 2567c736..a140f4d0 100644
--- a/frontend/lib/dashboard-client.ts
+++ b/frontend/lib/dashboard-client.ts
@@ -74,6 +74,7 @@ export function toCitySummary(detail: CityDetail): CitySummary {
deb: {
prediction: detail.deb?.prediction,
},
+ deviation_monitor: detail.deviation_monitor,
risk: {
level: detail.risk?.level,
warning: detail.risk?.warning,
diff --git a/frontend/lib/dashboard-official-sources.ts b/frontend/lib/dashboard-official-sources.ts
index 0bd8260a..41a319e9 100644
--- a/frontend/lib/dashboard-official-sources.ts
+++ b/frontend/lib/dashboard-official-sources.ts
@@ -58,40 +58,6 @@ const CITY_SPECIFIC_SOURCES: Record = {
kind: "metar",
},
],
- "shek kong": [
- {
- label: "香港天文台",
- href: "https://www.hko.gov.hk/en/index.html",
- kind: "agency",
- },
- {
- label: "HKO 区域天气数据",
- href: "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv",
- kind: "agency",
- },
- {
- label: "VHSK Timeseries",
- href: "https://www.weather.gov/wrh/timeseries?site=VHSK",
- kind: "metar",
- },
- ],
- "lau fau shan": [
- {
- label: "香港天文台",
- href: "https://www.hko.gov.hk/en/index.html",
- kind: "agency",
- },
- {
- label: "HKO 区域天气数据",
- href: "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv",
- kind: "agency",
- },
- {
- label: "HKO 实时读数页",
- href: "https://www.hko.gov.hk/textonly/v2/forecast/text_readings_e.htm",
- kind: "agency",
- },
- ],
taipei: [
{
label: "NOAA RCTP Timeseries",
@@ -150,6 +116,108 @@ const CITY_SPECIFIC_SOURCES: Record = {
kind: "metar",
},
],
+ "los angeles": [
+ {
+ label: "NWS Los Angeles/Oxnard",
+ href: "https://www.weather.gov/lox/",
+ kind: "agency",
+ },
+ {
+ label: "LAX Airport",
+ href: "https://www.flylax.com/",
+ kind: "airport",
+ },
+ {
+ label: "KLAX METAR",
+ href: "https://aviationweather.gov/data/metar/?id=KLAX&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
+ "san francisco": [
+ {
+ label: "NWS San Francisco Bay Area",
+ href: "https://www.weather.gov/mtr/",
+ kind: "agency",
+ },
+ {
+ label: "SFO Airport",
+ href: "https://www.flysfo.com/",
+ kind: "airport",
+ },
+ {
+ label: "KSFO METAR",
+ href: "https://aviationweather.gov/data/metar/?id=KSFO&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
+ aurora: [
+ {
+ label: "NWS Denver/Boulder",
+ href: "https://www.weather.gov/bou/",
+ kind: "agency",
+ },
+ {
+ label: "Buckley Space Force Base",
+ href: "https://www.buckley.spaceforce.mil/",
+ kind: "airport",
+ },
+ {
+ label: "KBKF METAR",
+ href: "https://aviationweather.gov/data/metar/?id=KBKF&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
+ austin: [
+ {
+ label: "NWS Austin/San Antonio",
+ href: "https://www.weather.gov/ewx/",
+ kind: "agency",
+ },
+ {
+ label: "Austin-Bergstrom Airport",
+ href: "https://www.austintexas.gov/airport",
+ kind: "airport",
+ },
+ {
+ label: "KAUS METAR",
+ href: "https://aviationweather.gov/data/metar/?id=KAUS&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
+ houston: [
+ {
+ label: "NWS Houston/Galveston",
+ href: "https://www.weather.gov/hgx/",
+ kind: "agency",
+ },
+ {
+ label: "William P. Hobby Airport",
+ href: "https://www.fly2houston.com/hobby",
+ kind: "airport",
+ },
+ {
+ label: "KHOU METAR",
+ href: "https://aviationweather.gov/data/metar/?id=KHOU&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
+ "mexico city": [
+ {
+ label: "SMN",
+ href: "https://smn.conagua.gob.mx/",
+ kind: "agency",
+ },
+ {
+ label: "AICM",
+ href: "https://www.aicm.com.mx/",
+ kind: "airport",
+ },
+ {
+ label: "MMMX METAR",
+ href: "https://aviationweather.gov/data/metar/?id=MMMX&decoded=1&taf=1",
+ kind: "metar",
+ },
+ ],
ankara: [
{
label: "MGM",
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 34201758..e6086cce 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -162,6 +162,7 @@ export interface CitySummary {
deb?: {
prediction?: number | null;
};
+ deviation_monitor?: DeviationMonitor;
risk?: {
level?: RiskLevel;
warning?: string | null;
@@ -169,6 +170,19 @@ export interface CitySummary {
updated_at?: string | null;
}
+export interface DeviationMonitor {
+ available?: boolean;
+ current_delta?: number | null;
+ reference_temp?: number | null;
+ direction?: "normal" | "cold" | "hot" | string;
+ severity?: "normal" | "light" | "strong" | string;
+ trend?: "stable" | "expanding" | "contracting" | string;
+ label_zh?: string | null;
+ label_en?: string | null;
+ trend_label_zh?: string | null;
+ trend_label_en?: string | null;
+}
+
export interface HourlySeries {
times?: string[];
temps?: Array;
@@ -301,6 +315,7 @@ export interface CityDetail {
forecast?: ForecastData;
multi_model?: Record;
deb?: DebForecast;
+ deviation_monitor?: DeviationMonitor;
probabilities?: {
mu?: number | null;
distribution?: ProbabilityBucket[];
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 1025d916..95d02b55 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -186,6 +186,7 @@ export interface ModelComparison {
ICON?: number;
GEM?: number;
JMA?: number;
+ LGBM?: number;
MGM?: number;
NWS?: number;
}
diff --git a/requirements.txt b/requirements.txt
index 8d746973..ca96ec99 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ pyTelegramBotAPI
python-dotenv
pytz
numpy
+lightgbm
web3
fastapi
uvicorn
diff --git a/scripts/report_lgbm_daily_high.py b/scripts/report_lgbm_daily_high.py
new file mode 100644
index 00000000..5ef2bfbb
--- /dev/null
+++ b/scripts/report_lgbm_daily_high.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import json
+import os
+import sys
+from typing import Any, Dict
+
+
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SCHEMA_PATH = os.path.join(ROOT_DIR, "artifacts", "models", "lgbm_daily_high_schema.json")
+
+
+def _load_schema(path: str) -> Dict[str, Any]:
+ with open(path, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ if not isinstance(data, dict):
+ raise SystemExit(f"Invalid schema payload in {path}")
+ return data
+
+
+def _fmt_metric(value: Any) -> str:
+ if value is None:
+ return "--"
+ try:
+ return f"{float(value):.3f}"
+ except Exception:
+ return str(value)
+
+
+def _winner(metrics: Dict[str, Any]) -> str:
+ candidates = {
+ "LGBM": metrics.get("lgbm_mae"),
+ "DEB": metrics.get("deb_mae"),
+ "Best Single": metrics.get("best_single_mae"),
+ "Median": metrics.get("median_mae"),
+ }
+ filtered = {k: float(v) for k, v in candidates.items() if v is not None}
+ if not filtered:
+ return "--"
+ return min(filtered.items(), key=lambda item: item[1])[0]
+
+
+def _print_block(label: str, metrics: Dict[str, Any]) -> None:
+ print(label)
+ print(f" Samples : {metrics.get('sample_count', 0)}")
+ print(f" LGBM MAE : {_fmt_metric(metrics.get('lgbm_mae'))}")
+ print(f" DEB MAE : {_fmt_metric(metrics.get('deb_mae'))}")
+ print(f" Best Single : {_fmt_metric(metrics.get('best_single_mae'))}")
+ print(f" Model Median : {_fmt_metric(metrics.get('median_mae'))}")
+ print(f" Winner : {_winner(metrics)}")
+
+
+def main() -> int:
+ path = sys.argv[1] if len(sys.argv) > 1 else SCHEMA_PATH
+ if not os.path.exists(path):
+ raise SystemExit(f"Schema file not found: {path}")
+
+ schema = _load_schema(path)
+ metrics = schema.get("metrics") or {}
+ validation = metrics.get("validation") or {}
+ full_sample = metrics.get("full_sample") or {}
+
+ print("LightGBM Daily High Report")
+ print(f" Target : {schema.get('target', '--')}")
+ print(f" Horizon : {schema.get('horizon', '--')}")
+ print(f" Sample Count : {schema.get('sample_count', 0)}")
+ print(f" Train Count : {schema.get('train_count', 0)}")
+ print(f" Valid Count : {schema.get('validation_count', 0)}")
+ print(f" Trained At : {schema.get('trained_at', '--')}")
+ print("")
+ _print_block("Validation", validation)
+ print("")
+ _print_block("Full Sample", full_sample)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/train_lgbm_daily_high.py b/scripts/train_lgbm_daily_high.py
new file mode 100644
index 00000000..f788b543
--- /dev/null
+++ b/scripts/train_lgbm_daily_high.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+import json
+import os
+import sys
+from datetime import datetime
+from typing import Any, Dict, List
+
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if ROOT_DIR not in sys.path:
+ sys.path.insert(0, ROOT_DIR)
+
+import lightgbm as lgb
+import numpy as np
+
+from src.models.lgbm_features import (
+ FEATURE_NAMES,
+ build_training_samples,
+ schema_payload,
+)
+
+
+MODEL_PATH = os.path.join(ROOT_DIR, "artifacts", "models", "lgbm_daily_high.txt")
+SCHEMA_PATH = os.path.join(ROOT_DIR, "artifacts", "models", "lgbm_daily_high_schema.json")
+
+
+def _mae(pairs: List[tuple[float, float]]) -> float | None:
+ if not pairs:
+ return None
+ return round(sum(abs(pred - actual) for pred, actual in pairs) / len(pairs), 3)
+
+
+def _best_single_forecast(sample: Dict[str, Any]) -> float | None:
+ target = float(sample["target"])
+ forecasts = sample.get("forecasts") or {}
+ best_value = None
+ best_error = None
+ for value in forecasts.values():
+ try:
+ numeric = float(value)
+ except Exception:
+ continue
+ error = abs(numeric - target)
+ if best_error is None or error < best_error:
+ best_error = error
+ best_value = numeric
+ return best_value
+
+
+def _chronological_split(samples: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+ if len(samples) < 12:
+ return samples, []
+ ordered = sorted(samples, key=lambda row: (row["date"], row["city"]))
+ validation_count = max(12, int(round(len(ordered) * 0.2)))
+ validation_count = min(validation_count, len(ordered) - 1)
+ if validation_count <= 0:
+ return ordered, []
+ return ordered[:-validation_count], ordered[-validation_count:]
+
+
+def _dataset_from_samples(samples: List[Dict[str, Any]]) -> tuple[np.ndarray, np.ndarray]:
+ features = np.asarray([row["vector"] for row in samples], dtype=np.float32)
+ targets = np.asarray([row["target"] for row in samples], dtype=np.float32)
+ return features, targets
+
+
+def _train_model(train_samples: List[Dict[str, Any]], valid_samples: List[Dict[str, Any]]) -> lgb.Booster:
+ train_x, train_y = _dataset_from_samples(train_samples)
+ train_data = lgb.Dataset(train_x, label=train_y, feature_name=FEATURE_NAMES, free_raw_data=True)
+ valid_sets = [train_data]
+ valid_names = ["train"]
+
+ params = {
+ "objective": "regression",
+ "metric": "l1",
+ "learning_rate": 0.05,
+ "num_leaves": 15,
+ "feature_fraction": 0.9,
+ "bagging_fraction": 0.9,
+ "bagging_freq": 1,
+ "min_data_in_leaf": 4,
+ "verbosity": -1,
+ "seed": 42,
+ }
+
+ callbacks = []
+ if valid_samples:
+ valid_x, valid_y = _dataset_from_samples(valid_samples)
+ valid_data = lgb.Dataset(valid_x, label=valid_y, feature_name=FEATURE_NAMES, reference=train_data)
+ valid_sets.append(valid_data)
+ valid_names.append("valid")
+ callbacks.append(lgb.early_stopping(stopping_rounds=15, verbose=False))
+
+ return lgb.train(
+ params=params,
+ train_set=train_data,
+ num_boost_round=120,
+ valid_sets=valid_sets,
+ valid_names=valid_names,
+ callbacks=callbacks,
+ )
+
+
+def _evaluate(booster: lgb.Booster, samples: List[Dict[str, Any]]) -> Dict[str, Any]:
+ if not samples:
+ return {
+ "sample_count": 0,
+ "lgbm_mae": None,
+ "deb_mae": None,
+ "best_single_mae": None,
+ "median_mae": None,
+ }
+
+ features, _ = _dataset_from_samples(samples)
+ preds = booster.predict(features, num_iteration=booster.best_iteration)
+ lgbm_pairs: List[tuple[float, float]] = []
+ deb_pairs: List[tuple[float, float]] = []
+ best_single_pairs: List[tuple[float, float]] = []
+ median_pairs: List[tuple[float, float]] = []
+
+ for sample, pred in zip(samples, preds):
+ actual = float(sample["target"])
+ lgbm_pairs.append((float(pred), actual))
+
+ deb_prediction = sample.get("deb_prediction")
+ if deb_prediction is not None:
+ deb_pairs.append((float(deb_prediction), actual))
+
+ best_single = _best_single_forecast(sample)
+ if best_single is not None:
+ best_single_pairs.append((best_single, actual))
+
+ median_prediction = (sample.get("features") or {}).get("model_median")
+ if median_prediction is not None:
+ median_pairs.append((float(median_prediction), actual))
+
+ return {
+ "sample_count": len(samples),
+ "lgbm_mae": _mae(lgbm_pairs),
+ "deb_mae": _mae(deb_pairs),
+ "best_single_mae": _mae(best_single_pairs),
+ "median_mae": _mae(median_pairs),
+ }
+
+
+def main() -> int:
+ samples = build_training_samples()
+ if len(samples) < 16:
+ raise SystemExit(f"Not enough supervised samples for LightGBM training: {len(samples)}")
+
+ train_samples, valid_samples = _chronological_split(samples)
+ booster = _train_model(train_samples, valid_samples)
+
+ all_features, all_targets = _dataset_from_samples(samples)
+ final_train = lgb.Dataset(all_features, label=all_targets, feature_name=FEATURE_NAMES, free_raw_data=True)
+ final_booster = lgb.train(
+ params={
+ "objective": "regression",
+ "metric": "l1",
+ "learning_rate": 0.05,
+ "num_leaves": 15,
+ "feature_fraction": 0.9,
+ "bagging_fraction": 0.9,
+ "bagging_freq": 1,
+ "min_data_in_leaf": 4,
+ "verbosity": -1,
+ "seed": 42,
+ },
+ train_set=final_train,
+ num_boost_round=max(int(booster.best_iteration or 60), 20),
+ )
+
+ os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
+ final_booster.save_model(MODEL_PATH)
+
+ metrics = {
+ "validation": _evaluate(booster, valid_samples),
+ "full_sample": _evaluate(final_booster, samples),
+ }
+ schema = schema_payload(
+ model_path=os.path.relpath(MODEL_PATH, ROOT_DIR),
+ sample_count=len(samples),
+ train_count=len(train_samples),
+ validation_count=len(valid_samples),
+ metrics=metrics,
+ )
+ schema["trained_at"] = datetime.utcnow().isoformat() + "Z"
+ with open(SCHEMA_PATH, "w", encoding="utf-8") as fh:
+ json.dump(schema, fh, ensure_ascii=False, indent=2)
+
+ print(json.dumps({"model_path": MODEL_PATH, "schema_path": SCHEMA_PATH, "metrics": metrics}, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/analysis/probability_snapshot_archive.py b/src/analysis/probability_snapshot_archive.py
index fe554306..c51c3cf7 100644
--- a/src/analysis/probability_snapshot_archive.py
+++ b/src/analysis/probability_snapshot_archive.py
@@ -186,6 +186,11 @@ def append_probability_snapshot(
ens_data: Optional[Dict[str, Any]],
current_forecasts: Optional[Dict[str, Any]],
max_so_far: Optional[float],
+ current_temp: Optional[float] = None,
+ humidity: Optional[float] = None,
+ wind_speed_kt: Optional[float] = None,
+ visibility_mi: Optional[float] = None,
+ local_hour: Optional[float] = None,
peak_status: Optional[str],
probabilities: Optional[List[Dict[str, Any]]],
shadow_probabilities: Optional[List[Dict[str, Any]]],
@@ -227,6 +232,13 @@ def append_probability_snapshot(
if _sf(value) is not None
},
"max_so_far": _sf(max_so_far),
+ "observation": {
+ "current_temp": _sf(current_temp),
+ "humidity": _sf(humidity),
+ "wind_speed_kt": _sf(wind_speed_kt),
+ "visibility_mi": _sf(visibility_mi),
+ "local_hour": _sf(local_hour),
+ },
"peak_status": peak_status,
"prob_snapshot": _compact_snapshot(probabilities),
"shadow_prob_snapshot": _compact_snapshot(shadow_probabilities),
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index e38325eb..6ff818bf 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -23,6 +23,7 @@ from src.analysis.probability_snapshot_archive import append_probability_snapsho
from src.analysis.settlement_rounding import apply_city_settlement, is_exact_settlement_city
from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
+from src.models.lgbm_daily_high import predict_lgbm_daily_high
SETTLEMENT_SOURCE_LABELS = {
"metar": "METAR",
@@ -418,6 +419,38 @@ def analyze_weather_trend(
else:
peak_status = "before"
+ if city_name and current_forecasts and deb_prediction is not None:
+ lgbm_prediction, _ = predict_lgbm_daily_high(
+ city_name=city_name,
+ current_forecasts=current_forecasts,
+ deb_prediction=deb_prediction,
+ current_temp=cur_temp,
+ max_so_far=max_so_far,
+ humidity=_sf(primary_current.get("humidity")),
+ wind_speed_kt=_sf(primary_current.get("wind_speed_kt")),
+ visibility_mi=_sf(primary_current.get("visibility_mi")),
+ local_hour=local_hour,
+ local_date=local_date_str,
+ peak_status=peak_status,
+ )
+ if lgbm_prediction is not None:
+ current_forecasts["LGBM"] = lgbm_prediction
+ blended_high, weight_info = calculate_dynamic_weights(
+ city_name, current_forecasts
+ )
+ if blended_high is not None:
+ deb_prediction = blended_high
+ deb_weights = weight_info
+ _deb_to_save = blended_high
+ if insights and "DEB 融合预测" in insights[0]:
+ insights[0] = (
+ f"🧬 DEB 融合预测:{blended_high}{temp_symbol} ({weight_info})"
+ )
+ if ai_features and "DEB系统已通过历史偏差矫正算出期待点是" in ai_features[0]:
+ ai_features[0] = (
+ f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {blended_high}{temp_symbol}。"
+ )
+
if trend_direction == "stagnant":
if peak_status == "before":
trend_desc = (
@@ -871,6 +904,11 @@ def analyze_weather_trend(
ens_data=ens_data,
current_forecasts=current_forecasts,
max_so_far=max_so_far,
+ current_temp=cur_temp,
+ humidity=_sf(primary_current.get("humidity")),
+ wind_speed_kt=_sf(primary_current.get("wind_speed_kt")),
+ visibility_mi=_sf(primary_current.get("visibility_mi")),
+ local_hour=local_hour_frac,
peak_status=peak_status,
probabilities=_prob_list,
shadow_probabilities=_shadow_prob_list,
diff --git a/src/data_collection/city_registry.py b/src/data_collection/city_registry.py
index 7c5f19d7..f9238be5 100644
--- a/src/data_collection/city_registry.py
+++ b/src/data_collection/city_registry.py
@@ -93,44 +93,6 @@ CITY_REGISTRY = {
"distance_km": 2.0,
"warning": "海风与地形共同作用,午后对流触发后温度回落可能偏快。",
},
- "shek kong": {
- "name": "Shek Kong",
- "lat": 22.4366,
- "lon": 114.0800,
- "icao": "VHSK",
- "settlement_source": "hko",
- "settlement_station_code": "SEK",
- "settlement_station_label": "Shek Kong",
- "settlement_station_candidates": ["Shek Kong"],
- "disable_aviationweather": True,
- "tz_offset": 28800,
- "use_fahrenheit": False,
- "is_major": False,
- "risk_level": "medium",
- "risk_emoji": "🟡",
- "airport_name": "石岗机场",
- "distance_km": 0.8,
- "warning": "HKO 结算取石岗站分钟级观测,机场报文与 HKO 站点最高温不可直接混用。",
- },
- "lau fau shan": {
- "name": "Lau Fau Shan",
- "lat": 22.4674,
- "lon": 113.9841,
- "icao": "LFS",
- "settlement_source": "hko",
- "settlement_station_code": "LFS",
- "settlement_station_label": "Lau Fau Shan",
- "settlement_station_candidates": ["Lau Fau Shan"],
- "disable_aviationweather": True,
- "tz_offset": 28800,
- "use_fahrenheit": False,
- "is_major": False,
- "risk_level": "medium",
- "risk_emoji": "🟡",
- "airport_name": "流浮山站",
- "distance_km": 0.6,
- "warning": "HKO 结算取流浮山站分钟级观测;该站不是机场 METAR,不能与 VHHH/VHSK 报文混用。",
- },
"taipei": {
"name": "Taipei",
"lat": 25.0777,
@@ -260,6 +222,90 @@ CITY_REGISTRY = {
"distance_km": 14.5,
"warning": "东河水汽可能在春季产生温差。",
},
+ "los angeles": {
+ "name": "Los Angeles",
+ "lat": 33.9416,
+ "lon": -118.4085,
+ "icao": "KLAX",
+ "tz_offset": -28800,
+ "use_fahrenheit": True,
+ "is_major": True,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "Los Angeles International Airport",
+ "distance_km": 29.0,
+ "warning": "海风与沿海层云对午后升温影响明显,LAX 口径常明显低于内陆城区。",
+ },
+ "san francisco": {
+ "name": "San Francisco",
+ "lat": 37.6213,
+ "lon": -122.3790,
+ "icao": "KSFO",
+ "tz_offset": -28800,
+ "use_fahrenheit": True,
+ "is_major": True,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "San Francisco International Airport",
+ "distance_km": 20.0,
+ "warning": "海湾冷空气和平流雾常压制机场升温,SFO 与市区体感差异可快速放大。",
+ },
+ "aurora": {
+ "name": "Aurora",
+ "lat": 39.7017,
+ "lon": -104.7518,
+ "icao": "KBKF",
+ "tz_offset": -25200,
+ "use_fahrenheit": True,
+ "is_major": False,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "Buckley Space Force Base",
+ "distance_km": 3.5,
+ "warning": "丹佛高原地形下日照与下沉增温切换快,Buckley 与 Denver 核心区午后峰值常有明显错位。",
+ },
+ "austin": {
+ "name": "Austin",
+ "lat": 30.1945,
+ "lon": -97.6699,
+ "icao": "KAUS",
+ "tz_offset": -21600,
+ "use_fahrenheit": True,
+ "is_major": True,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "Austin-Bergstrom International Airport",
+ "distance_km": 12.0,
+ "warning": "德州中部午后混合层增强快,干热与局地对流会让峰值窗口前后偏差放大。",
+ },
+ "houston": {
+ "name": "Houston",
+ "lat": 29.6454,
+ "lon": -95.2789,
+ "icao": "KHOU",
+ "tz_offset": -21600,
+ "use_fahrenheit": True,
+ "is_major": True,
+ "risk_level": "medium",
+ "risk_emoji": "🟡",
+ "airport_name": "William P. Hobby Airport",
+ "distance_km": 12.0,
+ "warning": "墨西哥湾水汽与海风回流显著,湿热条件下机场白天升温节奏容易偏慢。",
+ },
+ "mexico city": {
+ "name": "Mexico City",
+ "lat": 19.4363,
+ "lon": -99.0721,
+ "icao": "MMMX",
+ "tz_offset": -21600,
+ "use_fahrenheit": False,
+ "is_major": True,
+ "risk_level": "high",
+ "risk_emoji": "🔴",
+ "airport_name": "Benito Juárez International Airport",
+ "distance_km": 6.5,
+ "warning": "高海拔盆地城市,辐射增温与午后对流并存,峰值前后 1-2°C 偏差较常见。",
+ },
"chicago": {
"name": "Chicago",
"lat": 41.9742,
@@ -497,11 +543,15 @@ ALIASES = {
# English shortcuts
"ank": "ankara", "ist": "istanbul", "ltfm": "istanbul", "lon": "london", "par": "paris",
"nyc": "new york", "ny": "new york", "chi": "chicago",
+ "la": "los angeles", "lax": "los angeles", "losangeles": "los angeles",
+ "sf": "san francisco", "sfo": "san francisco", "sanfrancisco": "san francisco",
+ "aur": "aurora", "denver": "aurora", "buckley": "aurora", "kbkf": "aurora",
+ "aus": "austin", "kaus": "austin",
+ "hou": "houston", "khou": "houston", "hobby": "houston",
+ "cdmx": "mexico city", "mexicocity": "mexico city", "mmmx": "mexico city",
"dal": "dallas", "mia": "miami", "atl": "atlanta",
"sea": "seattle", "tor": "toronto", "sel": "seoul",
"seo": "seoul", "hkg": "hong kong", "hk": "hong kong",
- "vhsk": "shek kong", "shekkong": "shek kong",
- "lfs": "lau fau shan", "laufaushan": "lau fau shan",
"tpe": "taipei", "tp": "taipei", "taipei": "taipei",
"sha": "shanghai", "sh": "shanghai", "sin": "singapore",
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
@@ -517,6 +567,13 @@ ALIASES = {
"伦敦": "london",
"巴黎": "paris",
"纽约": "new york",
+ "洛杉矶": "los angeles",
+ "旧金山": "san francisco",
+ "奥罗拉": "aurora",
+ "丹佛": "aurora",
+ "奥斯汀": "austin",
+ "休斯顿": "houston",
+ "墨西哥城": "mexico city",
"芝加哥": "chicago",
"达拉斯": "dallas",
"迈阿密": "miami",
@@ -525,9 +582,6 @@ ALIASES = {
"多伦多": "toronto",
"首尔": "seoul",
"香港": "hong kong",
- "石岗": "shek kong",
- "石崗": "shek kong",
- "流浮山": "lau fau shan",
"台北": "taipei",
"臺北": "taipei",
"上海": "shanghai",
diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py
index 7098b7df..3dc0719d 100644
--- a/src/data_collection/weather_sources.py
+++ b/src/data_collection/weather_sources.py
@@ -35,11 +35,15 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"istanbul": ["LTFM", "LTBA", "LTFJ"],
"london": ["EGLL", "EGLC", "EGKK", "EGSS", "EGGW"],
"new york": ["KLGA", "KJFK", "KEWR", "KTEB", "KHPN"],
+ "los angeles": ["KLAX", "KBUR", "KLGB", "KSNA", "KVNY"],
+ "san francisco": ["KSFO", "KOAK", "KSJC", "KHAF"],
+ "aurora": ["KBKF", "KDEN", "KAPA", "KBJC"],
+ "austin": ["KAUS", "KEDC", "KSAT"],
+ "houston": ["KHOU", "KIAH", "KSGR", "KCXO"],
+ "mexico city": ["MMMX", "MMSM", "MMTO"],
"paris": ["LFPG", "LFPO", "LFPB"],
"seoul": ["RKSI", "RKSS"],
"hong kong": ["VHHH", "VMMC", "ZGSZ"],
- "shek kong": ["VHSK", "VHHH", "VMMC", "ZGSZ"],
- "lau fau shan": ["VHHH", "VMMC", "ZGSZ"],
"taipei": ["RCSS", "RCTP"],
"chengdu": ["ZUUU", "ZUTF"],
"chongqing": ["ZUCK", "ZUPS"],
@@ -81,6 +85,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"new york's central park",
"portland",
"denver",
+ "aurora",
"austin",
"san diego",
"detroit",
diff --git a/src/models/__init__.py b/src/models/__init__.py
new file mode 100644
index 00000000..0fb0a192
--- /dev/null
+++ b/src/models/__init__.py
@@ -0,0 +1 @@
+"""Model adapters and feature builders."""
diff --git a/src/models/lgbm_daily_high.py b/src/models/lgbm_daily_high.py
new file mode 100644
index 00000000..748c6659
--- /dev/null
+++ b/src/models/lgbm_daily_high.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import json
+import os
+from typing import Any, Dict, List, Optional, Tuple
+
+from loguru import logger
+
+from src.models.lgbm_features import (
+ FEATURE_NAMES,
+ build_runtime_feature_map,
+)
+
+_MODEL_CACHE: Dict[str, Any] = {"path": None, "mtime": None, "booster": None}
+_SCHEMA_CACHE: Dict[str, Any] = {"path": None, "mtime": None, "schema": None}
+
+
+def _sf(value: Any) -> Optional[float]:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except Exception:
+ return None
+
+
+def _truthy_env(name: str, default: str = "false") -> bool:
+ return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"}
+
+
+def lgbm_model_path() -> str:
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ return str(
+ os.getenv(
+ "POLYWEATHER_LGBM_MODEL_PATH",
+ os.path.join(root, "artifacts", "models", "lgbm_daily_high.txt"),
+ )
+ ).strip()
+
+
+def lgbm_schema_path() -> str:
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ return str(
+ os.getenv(
+ "POLYWEATHER_LGBM_SCHEMA_PATH",
+ os.path.join(root, "artifacts", "models", "lgbm_daily_high_schema.json"),
+ )
+ ).strip()
+
+
+def lgbm_min_history_points() -> int:
+ try:
+ return max(1, int(os.getenv("POLYWEATHER_LGBM_MIN_HISTORY_POINTS", "3")))
+ except Exception:
+ return 3
+
+
+def is_lgbm_enabled() -> bool:
+ return _truthy_env("POLYWEATHER_LGBM_ENABLED", "false")
+
+
+def _load_schema(schema_path: str) -> Optional[Dict[str, Any]]:
+ if not schema_path or not os.path.exists(schema_path):
+ return None
+ mtime = os.path.getmtime(schema_path)
+ if (
+ _SCHEMA_CACHE["schema"] is not None
+ and _SCHEMA_CACHE["path"] == schema_path
+ and _SCHEMA_CACHE["mtime"] == mtime
+ ):
+ return _SCHEMA_CACHE["schema"]
+ with open(schema_path, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ if not isinstance(data, dict):
+ return None
+ _SCHEMA_CACHE.update({"path": schema_path, "mtime": mtime, "schema": data})
+ return data
+
+
+def _load_booster(model_path: str):
+ if not model_path or not os.path.exists(model_path):
+ return None
+ mtime = os.path.getmtime(model_path)
+ if (
+ _MODEL_CACHE["booster"] is not None
+ and _MODEL_CACHE["path"] == model_path
+ and _MODEL_CACHE["mtime"] == mtime
+ ):
+ return _MODEL_CACHE["booster"]
+ try:
+ import lightgbm as lgb
+ except Exception as exc:
+ logger.warning(f"LGBM runtime dependency missing: {exc}")
+ return None
+ booster = lgb.Booster(model_file=model_path)
+ _MODEL_CACHE.update({"path": model_path, "mtime": mtime, "booster": booster})
+ return booster
+
+
+def _vector_from_features(
+ feature_map: Dict[str, Optional[float]],
+ schema: Optional[Dict[str, Any]],
+) -> List[float]:
+ feature_names = schema.get("feature_names") if isinstance(schema, dict) else None
+ ordered_names = feature_names if isinstance(feature_names, list) and feature_names else FEATURE_NAMES
+ vector: List[float] = []
+ for name in ordered_names:
+ value = feature_map.get(str(name))
+ vector.append(float("nan") if value is None else float(value))
+ return vector
+
+
+def predict_lgbm_daily_high(
+ *,
+ city_name: str,
+ current_forecasts: Dict[str, Any],
+ deb_prediction: Optional[float],
+ current_temp: Optional[float],
+ max_so_far: Optional[float],
+ humidity: Optional[float],
+ wind_speed_kt: Optional[float],
+ visibility_mi: Optional[float],
+ local_hour: int,
+ local_date: str,
+ peak_status: str,
+ history_data: Optional[Dict[str, Any]] = None,
+) -> Tuple[Optional[float], Dict[str, Any]]:
+ if not is_lgbm_enabled():
+ return None, {"reason": "disabled"}
+
+ schema = _load_schema(lgbm_schema_path())
+ booster = _load_booster(lgbm_model_path())
+ if schema is None or booster is None:
+ return None, {"reason": "artifact_missing"}
+
+ feature_map, meta = build_runtime_feature_map(
+ city_name=city_name,
+ current_forecasts=current_forecasts,
+ deb_prediction=deb_prediction,
+ current_temp=current_temp,
+ max_so_far=max_so_far,
+ humidity=humidity,
+ wind_speed_kt=wind_speed_kt,
+ visibility_mi=visibility_mi,
+ local_hour=local_hour,
+ local_date=local_date,
+ peak_status=peak_status,
+ history_data=history_data,
+ )
+ if not feature_map:
+ return None, meta
+
+ if int(meta.get("history_count") or 0) < lgbm_min_history_points():
+ return None, {
+ "reason": "insufficient_history",
+ "history_count": int(meta.get("history_count") or 0),
+ }
+
+ try:
+ vector = _vector_from_features(feature_map, schema)
+ prediction = booster.predict([vector], num_iteration=booster.best_iteration)
+ value = _sf(prediction[0] if prediction is not None else None)
+ if value is None:
+ return None, {"reason": "empty_prediction"}
+ return round(float(value), 1), {
+ "reason": "ok",
+ "history_count": int(meta.get("history_count") or 0),
+ }
+ except Exception as exc:
+ logger.warning(f"LGBM prediction failed for {city_name}: {exc}")
+ return None, {"reason": "predict_failed", "error": str(exc)}
diff --git a/src/models/lgbm_features.py b/src/models/lgbm_features.py
new file mode 100644
index 00000000..806e42b3
--- /dev/null
+++ b/src/models/lgbm_features.py
@@ -0,0 +1,356 @@
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime
+from statistics import mean
+from typing import Any, Dict, List, Optional, Tuple
+
+from src.analysis.deb_algorithm import load_history
+from src.data_collection.city_registry import ALIASES
+
+
+BASE_MODEL_COLUMNS: List[Tuple[str, str]] = [
+ ("Open-Meteo", "open_meteo"),
+ ("ECMWF", "ecmwf"),
+ ("GFS", "gfs"),
+ ("GEM", "gem"),
+ ("JMA", "jma"),
+ ("ICON", "icon"),
+ ("MGM", "mgm"),
+ ("NWS", "nws"),
+]
+
+FEATURE_NAMES: List[str] = [
+ "actual_high_lag_1",
+ "actual_high_lag_2",
+ "actual_high_lag_3",
+ "actual_high_lag_7",
+ "actual_high_mean_7",
+ "actual_high_mean_14",
+ "actual_high_trend_3",
+ *[column for _, column in BASE_MODEL_COLUMNS],
+ "deb_prediction",
+ "model_median",
+ "model_spread",
+ "current_temp",
+ "max_so_far",
+ "humidity",
+ "wind_speed_kt",
+ "visibility_mi",
+ "local_hour",
+ "month",
+ "weekday",
+ "peak_status_code",
+]
+
+PEAK_STATUS_CODES = {
+ "before": 0.0,
+ "in_window": 1.0,
+ "past": 2.0,
+}
+
+
+def _sf(value: Any) -> Optional[float]:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except Exception:
+ return None
+
+
+def _parse_date(value: Any) -> Optional[datetime]:
+ text = str(value or "").strip()
+ if not text:
+ return None
+ try:
+ return datetime.strptime(text, "%Y-%m-%d")
+ except Exception:
+ return None
+
+
+def _parse_timestamp(value: Any) -> Optional[datetime]:
+ text = str(value or "").strip()
+ if not text:
+ return None
+ try:
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+
+def _history_file_path() -> str:
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ return os.path.join(root, "data", "daily_records.json")
+
+
+def _snapshot_archive_path() -> str:
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ return os.path.join(root, "data", "probability_training_snapshots.jsonl")
+
+
+def _normalized_city_key(city_name: str) -> str:
+ city_key = str(city_name or "").strip().lower()
+ return ALIASES.get(city_key, city_key)
+
+
+def _safe_mean(values: List[Optional[float]]) -> Optional[float]:
+ valid = [float(v) for v in values if v is not None]
+ if not valid:
+ return None
+ return float(mean(valid))
+
+
+def _peak_status_code(value: Any) -> Optional[float]:
+ status = str(value or "").strip().lower()
+ if not status:
+ return None
+ return PEAK_STATUS_CODES.get(status, -1.0)
+
+
+def _compute_model_summary(features: Dict[str, Optional[float]]) -> Tuple[Optional[float], Optional[float]]:
+ values = [
+ features.get(column)
+ for _, column in BASE_MODEL_COLUMNS
+ if features.get(column) is not None
+ ]
+ values = [float(v) for v in values if v is not None]
+ if not values:
+ return None, None
+ ordered = sorted(values)
+ median_value = ordered[len(ordered) // 2]
+ spread_value = ordered[-1] - ordered[0] if len(ordered) >= 2 else 0.0
+ return float(median_value), float(spread_value)
+
+
+def load_snapshot_index(archive_path: Optional[str] = None) -> Dict[Tuple[str, str], Dict[str, Any]]:
+ path = archive_path or _snapshot_archive_path()
+ if not os.path.exists(path):
+ return {}
+
+ latest_rows: Dict[Tuple[str, str], Dict[str, Any]] = {}
+ with open(path, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ row = json.loads(line)
+ except Exception:
+ continue
+ if not isinstance(row, dict):
+ continue
+ city = _normalized_city_key(str(row.get("city") or ""))
+ date_str = str(row.get("date") or "").strip()
+ if not city or not date_str:
+ continue
+ key = (city, date_str)
+ current_best = latest_rows.get(key)
+ if current_best is None or str(row.get("timestamp") or "") >= str(
+ current_best.get("timestamp") or ""
+ ):
+ latest_rows[key] = row
+ return latest_rows
+
+
+def _extract_history_rows(
+ history_data: Dict[str, Any],
+ city_name: str,
+ exclude_date: Optional[str] = None,
+) -> List[Tuple[str, float]]:
+ city_key = _normalized_city_key(city_name)
+ city_rows = history_data.get(city_key) if isinstance(history_data, dict) else None
+ if not isinstance(city_rows, dict):
+ return []
+
+ rows: List[Tuple[str, float]] = []
+ for date_str, record in city_rows.items():
+ if exclude_date and str(date_str) >= str(exclude_date):
+ continue
+ if not isinstance(record, dict):
+ continue
+ actual = _sf(record.get("actual_high"))
+ if actual is None:
+ continue
+ rows.append((str(date_str), float(actual)))
+ rows.sort(key=lambda item: item[0])
+ return rows
+
+
+def _lag(values: List[float], distance: int) -> Optional[float]:
+ if len(values) < distance:
+ return None
+ return float(values[-distance])
+
+
+def build_runtime_feature_map(
+ *,
+ city_name: str,
+ current_forecasts: Dict[str, Any],
+ deb_prediction: Optional[float],
+ current_temp: Optional[float],
+ max_so_far: Optional[float],
+ humidity: Optional[float],
+ wind_speed_kt: Optional[float],
+ visibility_mi: Optional[float],
+ local_hour: int,
+ local_date: str,
+ peak_status: str,
+ history_data: Optional[Dict[str, Any]] = None,
+) -> Tuple[Optional[Dict[str, Optional[float]]], Dict[str, Any]]:
+ data = history_data if isinstance(history_data, dict) else load_history(_history_file_path())
+ history_rows = _extract_history_rows(data, city_name, exclude_date=local_date)
+ history_values = [value for _, value in history_rows]
+
+ if not history_values:
+ return None, {"reason": "no_history", "history_count": 0}
+
+ date_obj = _parse_date(local_date)
+ if date_obj is None:
+ return None, {"reason": "invalid_date", "history_count": len(history_values)}
+
+ features: Dict[str, Optional[float]] = {
+ "actual_high_lag_1": _lag(history_values, 1),
+ "actual_high_lag_2": _lag(history_values, 2),
+ "actual_high_lag_3": _lag(history_values, 3),
+ "actual_high_lag_7": _lag(history_values, 7),
+ "actual_high_mean_7": _safe_mean(history_values[-7:]),
+ "actual_high_mean_14": _safe_mean(history_values[-14:]),
+ "actual_high_trend_3": (
+ history_values[-1] - history_values[-3] if len(history_values) >= 3 else None
+ ),
+ "deb_prediction": _sf(deb_prediction),
+ "current_temp": _sf(current_temp),
+ "max_so_far": _sf(max_so_far),
+ "humidity": _sf(humidity),
+ "wind_speed_kt": _sf(wind_speed_kt),
+ "visibility_mi": _sf(visibility_mi),
+ "local_hour": float(local_hour),
+ "month": float(date_obj.month),
+ "weekday": float(date_obj.weekday()),
+ "peak_status_code": _peak_status_code(peak_status),
+ }
+
+ for model_name, column in BASE_MODEL_COLUMNS:
+ features[column] = _sf(current_forecasts.get(model_name))
+
+ model_median, model_spread = _compute_model_summary(features)
+ features["model_median"] = model_median
+ features["model_spread"] = model_spread
+
+ return features, {
+ "reason": "ok",
+ "history_count": len(history_values),
+ }
+
+
+def _features_to_vector(features: Dict[str, Optional[float]], feature_names: Optional[List[str]] = None) -> List[float]:
+ ordered_names = feature_names or FEATURE_NAMES
+ vector: List[float] = []
+ for name in ordered_names:
+ value = features.get(name)
+ vector.append(float("nan") if value is None else float(value))
+ return vector
+
+
+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]]:
+ data = history_data if isinstance(history_data, dict) else load_history(_history_file_path())
+ snapshots = snapshot_index if isinstance(snapshot_index, dict) else load_snapshot_index()
+ samples: List[Dict[str, Any]] = []
+
+ for city_name, city_records in (data or {}).items():
+ if not isinstance(city_records, dict):
+ continue
+ ordered_dates = sorted(city_records.keys())
+ for date_str in ordered_dates:
+ record = city_records.get(date_str)
+ if not isinstance(record, dict):
+ continue
+ target = _sf(record.get("actual_high"))
+ forecasts = record.get("forecasts") if isinstance(record.get("forecasts"), dict) else {}
+ if target is None or not forecasts:
+ continue
+
+ feature_map, meta = build_runtime_feature_map(
+ city_name=city_name,
+ current_forecasts=forecasts,
+ deb_prediction=_sf(record.get("deb_prediction")),
+ current_temp=None,
+ max_so_far=None,
+ humidity=None,
+ wind_speed_kt=None,
+ visibility_mi=None,
+ local_hour=12,
+ local_date=str(date_str),
+ peak_status="before",
+ history_data=data,
+ )
+ if not feature_map:
+ continue
+
+ snapshot = snapshots.get((_normalized_city_key(city_name), str(date_str))) or {}
+ observation = snapshot.get("observation") if isinstance(snapshot.get("observation"), dict) else {}
+ feature_map["max_so_far"] = _sf(snapshot.get("max_so_far"))
+ feature_map["current_temp"] = _sf(observation.get("current_temp"))
+ feature_map["humidity"] = _sf(observation.get("humidity"))
+ feature_map["wind_speed_kt"] = _sf(observation.get("wind_speed_kt"))
+ feature_map["visibility_mi"] = _sf(observation.get("visibility_mi"))
+ local_hour = _sf(observation.get("local_hour"))
+ if local_hour is not None:
+ feature_map["local_hour"] = local_hour
+ else:
+ timestamp = _parse_timestamp(snapshot.get("timestamp"))
+ if timestamp is not None:
+ feature_map["local_hour"] = float(timestamp.hour)
+ peak_code = _peak_status_code(snapshot.get("peak_status"))
+ if peak_code is not None:
+ feature_map["peak_status_code"] = peak_code
+ model_median, model_spread = _compute_model_summary(feature_map)
+ feature_map["model_median"] = model_median
+ feature_map["model_spread"] = model_spread
+
+ samples.append(
+ {
+ "city": _normalized_city_key(city_name),
+ "date": str(date_str),
+ "target": float(target),
+ "features": feature_map,
+ "vector": _features_to_vector(feature_map),
+ "history_count": int(meta.get("history_count") or 0),
+ "deb_prediction": _sf(record.get("deb_prediction")),
+ "forecasts": {
+ key: _sf(value)
+ for key, value in forecasts.items()
+ if _sf(value) is not None
+ },
+ }
+ )
+ samples.sort(key=lambda row: (row["date"], row["city"]))
+ return samples
+
+
+def schema_payload(
+ *,
+ model_path: str,
+ sample_count: int,
+ train_count: int,
+ validation_count: int,
+ metrics: Dict[str, Any],
+) -> Dict[str, Any]:
+ return {
+ "model_type": "LightGBMRegressor",
+ "target": "actual_high",
+ "horizon": "D0",
+ "feature_names": FEATURE_NAMES,
+ "base_model_columns": [column for _, column in BASE_MODEL_COLUMNS],
+ "model_path": model_path,
+ "sample_count": sample_count,
+ "train_count": train_count,
+ "validation_count": validation_count,
+ "metrics": metrics,
+ "generated_at": datetime.utcnow().isoformat() + "Z",
+ }
diff --git a/tests/test_lgbm_daily_high.py b/tests/test_lgbm_daily_high.py
new file mode 100644
index 00000000..a5a0db82
--- /dev/null
+++ b/tests/test_lgbm_daily_high.py
@@ -0,0 +1,93 @@
+import src.models.lgbm_daily_high as runtime
+
+
+class _FakeBooster:
+ best_iteration = 7
+
+ def predict(self, rows, num_iteration=None):
+ assert len(rows) == 1
+ return [14.36]
+
+
+def test_predict_lgbm_daily_high_skips_when_disabled(monkeypatch):
+ monkeypatch.setenv("POLYWEATHER_LGBM_ENABLED", "false")
+ prediction, meta = runtime.predict_lgbm_daily_high(
+ city_name="ankara",
+ current_forecasts={"Open-Meteo": 12.4},
+ deb_prediction=12.3,
+ current_temp=11.0,
+ max_so_far=11.4,
+ humidity=62.0,
+ wind_speed_kt=8.0,
+ visibility_mi=6.0,
+ local_hour=10,
+ local_date="2026-03-24",
+ peak_status="before",
+ history_data={},
+ )
+ assert prediction is None
+ assert meta["reason"] == "disabled"
+
+
+def test_predict_lgbm_daily_high_returns_prediction(monkeypatch):
+ monkeypatch.setenv("POLYWEATHER_LGBM_ENABLED", "true")
+ monkeypatch.setenv("POLYWEATHER_LGBM_MIN_HISTORY_POINTS", "3")
+ monkeypatch.setattr(runtime, "_load_schema", lambda path: {"feature_names": runtime.FEATURE_NAMES})
+ monkeypatch.setattr(runtime, "_load_booster", lambda path: _FakeBooster())
+
+ history_data = {
+ "ankara": {
+ "2026-03-20": {"actual_high": 10.0},
+ "2026-03-21": {"actual_high": 11.0},
+ "2026-03-22": {"actual_high": 13.0},
+ "2026-03-23": {"actual_high": 12.0},
+ }
+ }
+ prediction, meta = runtime.predict_lgbm_daily_high(
+ city_name="ankara",
+ current_forecasts={"Open-Meteo": 12.4, "ECMWF": 12.1, "GFS": 11.9},
+ deb_prediction=12.3,
+ current_temp=11.0,
+ max_so_far=11.4,
+ humidity=62.0,
+ wind_speed_kt=8.0,
+ visibility_mi=6.0,
+ local_hour=10,
+ local_date="2026-03-24",
+ peak_status="before",
+ history_data=history_data,
+ )
+ assert prediction == 14.4
+ assert meta["reason"] == "ok"
+ assert meta["history_count"] == 4
+
+
+def test_predict_lgbm_daily_high_requires_min_history(monkeypatch):
+ monkeypatch.setenv("POLYWEATHER_LGBM_ENABLED", "true")
+ monkeypatch.setenv("POLYWEATHER_LGBM_MIN_HISTORY_POINTS", "5")
+ monkeypatch.setattr(runtime, "_load_schema", lambda path: {"feature_names": runtime.FEATURE_NAMES})
+ monkeypatch.setattr(runtime, "_load_booster", lambda path: _FakeBooster())
+
+ history_data = {
+ "ankara": {
+ "2026-03-21": {"actual_high": 11.0},
+ "2026-03-22": {"actual_high": 13.0},
+ "2026-03-23": {"actual_high": 12.0},
+ }
+ }
+ prediction, meta = runtime.predict_lgbm_daily_high(
+ city_name="ankara",
+ current_forecasts={"Open-Meteo": 12.4},
+ deb_prediction=12.3,
+ current_temp=11.0,
+ max_so_far=11.4,
+ humidity=62.0,
+ wind_speed_kt=8.0,
+ visibility_mi=6.0,
+ local_hour=10,
+ local_date="2026-03-24",
+ peak_status="before",
+ history_data=history_data,
+ )
+ assert prediction is None
+ assert meta["reason"] == "insufficient_history"
diff --git a/tests/test_lgbm_features.py b/tests/test_lgbm_features.py
new file mode 100644
index 00000000..58fc7655
--- /dev/null
+++ b/tests/test_lgbm_features.py
@@ -0,0 +1,61 @@
+from src.models.lgbm_features import build_runtime_feature_map
+
+
+def test_build_runtime_feature_map_derives_history_and_model_summary():
+ history_data = {
+ "ankara": {
+ "2026-03-20": {"actual_high": 10.0},
+ "2026-03-21": {"actual_high": 11.0},
+ "2026-03-22": {"actual_high": 13.0},
+ "2026-03-23": {"actual_high": 12.0},
+ }
+ }
+
+ feature_map, meta = build_runtime_feature_map(
+ city_name="ankara",
+ current_forecasts={
+ "Open-Meteo": 12.4,
+ "ECMWF": 12.1,
+ "GFS": 11.9,
+ "GEM": 12.8,
+ },
+ deb_prediction=12.3,
+ current_temp=11.0,
+ max_so_far=11.4,
+ humidity=62.0,
+ wind_speed_kt=8.0,
+ visibility_mi=6.0,
+ local_hour=10,
+ local_date="2026-03-24",
+ peak_status="before",
+ history_data=history_data,
+ )
+
+ assert meta["reason"] == "ok"
+ assert meta["history_count"] == 4
+ assert feature_map["actual_high_lag_1"] == 12.0
+ assert feature_map["actual_high_lag_2"] == 13.0
+ assert feature_map["actual_high_trend_3"] == 1.0
+ assert feature_map["model_median"] == 12.4
+ assert round(feature_map["model_spread"], 3) == 0.9
+ assert feature_map["peak_status_code"] == 0.0
+
+
+def test_build_runtime_feature_map_returns_none_without_history():
+ feature_map, meta = build_runtime_feature_map(
+ city_name="unknown-city",
+ current_forecasts={"Open-Meteo": 12.4},
+ deb_prediction=12.3,
+ current_temp=11.0,
+ max_so_far=11.4,
+ humidity=62.0,
+ wind_speed_kt=8.0,
+ visibility_mi=6.0,
+ local_hour=10,
+ local_date="2026-03-24",
+ peak_status="before",
+ history_data={},
+ )
+
+ assert feature_map is None
+ assert meta["reason"] == "no_history"
diff --git a/web/analysis_service.py b/web/analysis_service.py
index 968cf130..6f9dbd29 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -24,6 +24,162 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement
from src.analysis.metar_narrator import describe_metar_report
from src.data_collection.city_registry import ALIASES
+from src.models.lgbm_daily_high import predict_lgbm_daily_high
+
+
+def _interpolate_hourly_value(
+ times: list,
+ values: list,
+ local_date: str,
+ target_hour_frac: float,
+) -> Optional[float]:
+ points = []
+ for ts, raw_value in zip(times or [], values or []):
+ if not str(ts).startswith(local_date):
+ continue
+ value = _sf(raw_value)
+ if value is None:
+ continue
+ try:
+ hh_mm = str(ts).split("T")[1]
+ hour = int(hh_mm[:2])
+ minute = int(hh_mm[3:5]) if len(hh_mm) >= 5 else 0
+ except Exception:
+ continue
+ points.append((hour + minute / 60.0, value))
+
+ if not points:
+ return None
+ points.sort(key=lambda item: item[0])
+
+ if target_hour_frac <= points[0][0]:
+ return float(points[0][1])
+ if target_hour_frac >= points[-1][0]:
+ return float(points[-1][1])
+
+ for idx in range(1, len(points)):
+ left_hour, left_value = points[idx - 1]
+ right_hour, right_value = points[idx]
+ if target_hour_frac > right_hour:
+ continue
+ if right_hour == left_hour:
+ return float(right_value)
+ ratio = (target_hour_frac - left_hour) / (right_hour - left_hour)
+ return float(left_value + (right_value - left_value) * ratio)
+
+ return float(points[-1][1])
+
+
+def _build_deviation_monitor(
+ *,
+ current_temp: Optional[float],
+ deb_prediction: Optional[float],
+ om_today: Optional[float],
+ hourly_times: list,
+ hourly_temps: list,
+ local_date: str,
+ local_hour_frac: float,
+ observation_points: list,
+) -> Dict[str, Any]:
+ if current_temp is None or deb_prediction is None or om_today is None:
+ return {}
+
+ offset = _sf(deb_prediction) - _sf(om_today)
+ if offset is None:
+ return {}
+
+ expected_now = _interpolate_hourly_value(
+ hourly_times,
+ [(_sf(value) + offset) if _sf(value) is not None else None for value in hourly_temps],
+ local_date,
+ local_hour_frac,
+ )
+ if expected_now is None:
+ return {}
+
+ delta = float(current_temp) - expected_now
+ abs_delta = abs(delta)
+ if abs_delta < 0.8:
+ direction = "normal"
+ severity = "normal"
+ elif delta <= -1.8:
+ direction = "cold"
+ severity = "strong"
+ elif delta >= 1.8:
+ direction = "hot"
+ severity = "strong"
+ elif delta < 0:
+ direction = "cold"
+ severity = "light"
+ else:
+ direction = "hot"
+ severity = "light"
+
+ deviation_series = []
+ for item in observation_points or []:
+ if not isinstance(item, dict):
+ continue
+ obs_temp = _sf(item.get("temp"))
+ raw_time = str(item.get("time") or "").strip()
+ if obs_temp is None:
+ continue
+ match = re.search(r"(\d{1,2}):(\d{2})", raw_time)
+ if not match:
+ continue
+ obs_hour_frac = int(match.group(1)) + int(match.group(2)) / 60.0
+ ref_temp = _interpolate_hourly_value(
+ hourly_times,
+ [(_sf(value) + offset) if _sf(value) is not None else None for value in hourly_temps],
+ local_date,
+ obs_hour_frac,
+ )
+ if ref_temp is None:
+ continue
+ deviation_series.append(float(obs_temp) - ref_temp)
+
+ trend = "stable"
+ if len(deviation_series) >= 2:
+ latest = deviation_series[-1]
+ previous = deviation_series[-2]
+ if latest * previous > 0:
+ if abs(latest) - abs(previous) >= 0.3:
+ trend = "expanding"
+ elif abs(previous) - abs(latest) >= 0.3:
+ trend = "contracting"
+
+ if direction == "normal":
+ label_zh = f"正常 ±{abs_delta:.1f}°C"
+ label_en = f"Normal ±{abs_delta:.1f}°C"
+ elif direction == "cold":
+ label_zh = f"偏冷 {delta:.1f}°C"
+ label_en = f"Cool bias {delta:.1f}°C"
+ else:
+ label_zh = f"偏热 +{abs_delta:.1f}°C"
+ label_en = f"Warm bias +{abs_delta:.1f}°C"
+
+ trend_zh = {
+ "contracting": "收敛中",
+ "expanding": "扩大中",
+ "stable": "稳定",
+ }.get(trend, "稳定")
+ trend_en = {
+ "contracting": "contracting",
+ "expanding": "expanding",
+ "stable": "stable",
+ }.get(trend, "stable")
+
+ return {
+ "available": True,
+ "current_delta": round(delta, 1),
+ "reference_temp": round(expected_now, 1),
+ "direction": direction,
+ "severity": severity,
+ "trend": trend,
+ "label_zh": label_zh,
+ "label_en": label_en,
+ "trend_label_zh": trend_zh,
+ "trend_label_en": trend_en,
+ }
def _wind_components(speed: Optional[float], direction: Optional[float]) -> tuple[Optional[float], Optional[float]]:
if speed is None or direction is None:
@@ -959,6 +1115,40 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
else:
peak_status = "before"
+ if current_forecasts and deb_val is not None:
+ lgbm_val, _ = predict_lgbm_daily_high(
+ city_name=city,
+ current_forecasts=current_forecasts,
+ deb_prediction=deb_val,
+ current_temp=cur_temp,
+ max_so_far=max_so_far,
+ humidity=_sf(primary_current.get("humidity")),
+ wind_speed_kt=_sf(primary_current.get("wind_speed_kt")),
+ visibility_mi=_sf(primary_current.get("visibility_mi")),
+ local_hour=local_hour,
+ local_date=local_date_str,
+ peak_status=peak_status,
+ )
+ if lgbm_val is not None:
+ current_forecasts["LGBM"] = lgbm_val
+ blended, winfo = calculate_dynamic_weights(city, current_forecasts)
+ if blended is not None:
+ deb_val = blended
+ deb_weights = winfo
+
+ deviation_monitor = _build_deviation_monitor(
+ current_temp=cur_temp,
+ deb_prediction=deb_val,
+ om_today=om_today,
+ hourly_times=h_times,
+ hourly_temps=h_temps,
+ local_date=local_date_str,
+ local_hour_frac=local_hour_frac,
+ observation_points=(
+ settlement_today_obs if settlement_today_obs else metar_today_obs_payload
+ ),
+ )
+
# ── 10. Shared analysis (probability, trend, AI) via trend_engine ──
# This single call replaces the duplicate probability engine, dead market
# detection, forecast bust grading, and AI context building.
@@ -1335,6 +1525,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"multi_model": {k: v for k, v in current_forecasts.items() if v is not None},
"multi_model_daily": multi_model_daily,
"deb": {"prediction": deb_val, "weights_info": deb_weights},
+ "deviation_monitor": deviation_monitor,
"ensemble": ens_data,
"probabilities": {
"mu": round(mu, 1) if mu is not None else None,
@@ -1398,6 +1589,7 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
"settlement_source_label": data.get("current", {}).get("settlement_source_label"),
},
"deb": {"prediction": data.get("deb", {}).get("prediction")},
+ "deviation_monitor": data.get("deviation_monitor") or {},
"risk": {
"level": data.get("risk", {}).get("level"),
"warning": data.get("risk", {}).get("warning"),