Add LightGBM daily high forecasting pipeline

This commit is contained in:
2569718930@qq.com
2026-03-29 23:29:17 +08:00
parent 4a2db4727b
commit 0cc4f4f0d3
25 changed files with 3038 additions and 82 deletions
+4
View File
@@ -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
+1
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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"
}
@@ -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}
+310
View File
@@ -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
```
+33 -3
View File
@@ -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 (
<button
@@ -211,7 +229,19 @@ export function CitySidebar() {
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
</span>
<span className="city-max-info">{peakTempText}</span>
<span
className={clsx(
"city-max-info",
deviationText && "city-deviation-info",
deviationText &&
`city-deviation-${deviationDirection}`,
deviationText &&
deviationSeverity === "strong" &&
"strong",
)}
>
{secondaryText}
</span>
</div>
</button>
);
@@ -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;
+1
View File
@@ -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,
+102 -34
View File
@@ -58,40 +58,6 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
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<string, OfficialSourceLink[]> = {
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",
+15
View File
@@ -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<number | null>;
@@ -301,6 +315,7 @@ export interface CityDetail {
forecast?: ForecastData;
multi_model?: Record<string, number | null>;
deb?: DebForecast;
deviation_monitor?: DeviationMonitor;
probabilities?: {
mu?: number | null;
distribution?: ProbabilityBucket[];
+1
View File
@@ -186,6 +186,7 @@ export interface ModelComparison {
ICON?: number;
GEM?: number;
JMA?: number;
LGBM?: number;
MGM?: number;
NWS?: number;
}
+1
View File
@@ -4,6 +4,7 @@ pyTelegramBotAPI
python-dotenv
pytz
numpy
lightgbm
web3
fastapi
uvicorn
+78
View File
@@ -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())
+196
View File
@@ -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())
@@ -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),
+38
View File
@@ -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"🧬 <b>DEB 融合预测</b><b>{blended_high}{temp_symbol}</b> ({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,
+97 -43
View File
@@ -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",
+7 -2
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
"""Model adapters and feature builders."""
+171
View File
@@ -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)}
+356
View File
@@ -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",
}
+93
View File
@@ -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"
+61
View File
@@ -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"
+192
View File
@@ -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"),