Add LightGBM daily high forecasting pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -186,6 +186,7 @@ export interface ModelComparison {
|
||||
ICON?: number;
|
||||
GEM?: number;
|
||||
JMA?: number;
|
||||
LGBM?: number;
|
||||
MGM?: number;
|
||||
NWS?: number;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pyTelegramBotAPI
|
||||
python-dotenv
|
||||
pytz
|
||||
numpy
|
||||
lightgbm
|
||||
web3
|
||||
fastapi
|
||||
uvicorn
|
||||
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Model adapters and feature builders."""
|
||||
@@ -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)}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user