From 8609376c59fe7184bb926af058304669dd133b37 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Wed, 27 May 2026 21:40:26 +0800
Subject: [PATCH] feat: add versioned DEB bias backtesting
---
.env.example | 6 +-
.github/workflows/ci.yml | 3 +
.gitignore | 1 +
README.md | 4 +-
README_ZH.md | 6 +-
docker-compose.yml | 3 +
docs/MODEL_STACK_AND_DEB_ZH.md | 42 +++-
frontend/.env.example | 6 +-
frontend/.env.local | 6 +-
frontend/.env.production | 3 +
frontend/Dockerfile | 6 +
frontend/README.md | 6 +-
frontend/lib/dashboard-types.ts | 5 +
scripts/backtest_deb_versions.py | 70 ++++++
src/analysis/deb_algorithm.py | 76 +++++++
src/analysis/deb_evaluation.py | 319 +++++++++++++++++++++++++++
src/analysis/trend_engine.py | 33 ++-
tests/test_deb_evaluation_upgrade.py | 121 ++++++++++
tests/test_deb_model_family.py | 37 ++++
web/analysis_service.py | 83 +++++--
20 files changed, 792 insertions(+), 44 deletions(-)
create mode 100644 scripts/backtest_deb_versions.py
create mode 100644 src/analysis/deb_evaluation.py
create mode 100644 tests/test_deb_evaluation_upgrade.py
diff --git a/.env.example b/.env.example
index b12b1466..3ac5b2b3 100644
--- a/.env.example
+++ b/.env.example
@@ -9,7 +9,7 @@
########################################
ENV=production
LOG_LEVEL=INFO
-POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
+POLYWEATHER_MAP_URL=https://polyweather.top/
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
@@ -21,7 +21,7 @@ GID=1000
POLYWEATHER_STATE_STORAGE_MODE=sqlite
# Backend CORS allowlist. Add your Vercel production/preview domains when
# NEXT_PUBLIC_POLYWEATHER_API_BASE_URL points browsers directly at this backend.
-WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app
+WEB_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://polyweather.top,https://www.polyweather.top,https://api.polyweather.top
########################################
# 2) Telegram bot minimal
@@ -39,7 +39,7 @@ TELEGRAM_QUERY_TOPIC_CHAT_ID=
TELEGRAM_QUERY_TOPIC_ID=
TELEGRAM_QUERY_TOPIC_MAP=
POLYWEATHER_BOT_GROUP_INVITE_URL=
-POLYWEATHER_APP_URL=https://polyweather-pro.vercel.app
+POLYWEATHER_APP_URL=https://polyweather.top
# Global Telegram auto-push copy: both, en, or zh. Module-specific vars can override it.
TELEGRAM_PUSH_LANGUAGE=both
# High-frequency airport push loop. Keep workers at 1 on shared VPS.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26c8c091..543deb36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -112,6 +112,9 @@ jobs:
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL || 'https://polyweather.top' }}
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=${{ secrets.NEXT_PUBLIC_POLYWEATHER_API_BASE_URL || '' }}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
+ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '' }}
+ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com' }}
+ NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=${{ secrets.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || 'polyweather.top,www.polyweather.top' }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 918f5e3f..1db2c2f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ data/*.db
data/*.db-*
data/*.db.*
data/*.json
+data/*backtest*.csv
data/logs/
data/historical/
data/cache/
diff --git a/README.md b/README.md
index 723b2c86..3e4c06d7 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Production weather-intelligence stack for temperature settlement markets.
-Official dashboard: [polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
+Official dashboard: [polyweather.top](https://polyweather.top/)
中文说明: [README_ZH.md](README_ZH.md)
Public docs center: `/docs/intro` on the main site (bilingual product documentation, including intraday analysis, calibrated probability, model stack, TAF, settlement sources, history, and extension).
@@ -162,7 +162,7 @@ curl http://127.0.0.1:8000/metrics
### Frontend cache headers
```bash
-./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
+./scripts/validate_frontend_cache.sh "https://polyweather.top"
```
### Payment auto-reconciliation logs
diff --git a/README_ZH.md b/README_ZH.md
index 8154e8c9..decfb821 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -2,7 +2,7 @@
面向温度结算市场的生产级气象情报系统。
-官方看板:[polyweather-pro.vercel.app](https://polyweather-pro.vercel.app/)
+官方看板:[polyweather.top](https://polyweather.top/)
## 产品截图
@@ -154,7 +154,7 @@ curl http://127.0.0.1:8000/metrics
### 外部监控栈
```bash
-./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
+./scripts/validate_frontend_cache.sh "https://polyweather.top"
```
### 支付自动补单日志
@@ -187,7 +187,7 @@ curl http://127.0.0.1:8000/api/payments/runtime
### 运营后台
-- 前端入口:`https://polyweather-pro.vercel.app/ops`
+- 前端入口:`https://polyweather.top/ops`
- 后端需配置:
```env
diff --git a/docker-compose.yml b/docker-compose.yml
index a0735e1c..8c038c11 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -60,9 +60,12 @@ services:
environment:
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL: ${NEXT_PUBLIC_POLYWEATHER_API_BASE_URL:-}
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS: 'false'
+ NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS: ${NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS:-polyweather.top,www.polyweather.top}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://polyweather.top}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
+ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL: ${NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL:-https://polygon-bor-rpc.publicnode.com}
+ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: ${NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID:-}
POLYWEATHER_API_BASE_URL: ${POLYWEATHER_API_BASE_URL:-http://polyweather_web:8000}
POLYWEATHER_AUTH_ENABLED: ${POLYWEATHER_AUTH_ENABLED:-true}
POLYWEATHER_AUTH_REQUIRED: ${POLYWEATHER_AUTH_REQUIRED:-true}
diff --git a/docs/MODEL_STACK_AND_DEB_ZH.md b/docs/MODEL_STACK_AND_DEB_ZH.md
index 04db8c10..0b747239 100644
--- a/docs/MODEL_STACK_AND_DEB_ZH.md
+++ b/docs/MODEL_STACK_AND_DEB_ZH.md
@@ -2,7 +2,7 @@
本文档记录 PolyWeather 当前开放模型接入、区域覆盖差异,以及 DEB 在新增模型后的计权规则。
-最后更新:`2026-04-18`
+最后更新:`2026-05-27`
## 1. 接入方式
@@ -165,11 +165,47 @@ raw current_forecasts
-> 按模型家族去重
-> 历史 MAE 统计
-> MAE 倒数权重
- -> 输出 blended_high + weights_info
+ -> 输出 raw blended_high
+ -> recent signed-bias correction
+ -> 输出 production prediction + raw_prediction + version
```
当 `weights_info` 出现 `家族去重`,表示当前输入模型数量多于 DEB 实际入模数量,系统已先折叠同家族模型。
+### 5.1 版本化预测与偏差校正
+
+DEB 原始融合逻辑不推倒重写,`calculate_dynamic_weights(...)` 仍作为 raw baseline。线上生产入口使用 `calculate_deb_prediction(...)` 包装 raw baseline,并在有足够历史样本时追加城市级 recent signed-bias correction。
+
+当前版本:
+
+- `deb_v1_raw`:原始 DEB,最近模型误差倒数加权后的融合值。
+- `deb_v1_recent_bias_corrected`:在 raw DEB 上叠加最近已结算样本的有符号偏差校正。偏差使用 shrinkage,样本少时自动收缩,避免单日异常过拟合。
+
+API `deb` payload 会保留:
+
+- `prediction`:当前生产使用值。
+- `raw_prediction`:未经 recent-bias correction 的原始 DEB。
+- `version`:生产值对应的 DEB 版本。
+- `bias_adjustment` / `bias_samples`:城市级偏差校正幅度与训练样本数。
+- `intraday_adjustment`:网页 full detail 中额外的日内观测路径修正,仅用于当前日实时展示。
+
+版本化回测命令:
+
+```bash
+python scripts/backtest_deb_versions.py \
+ --output-json data/deb_backtest_latest.json \
+ --output-csv data/deb_backtest_latest.csv
+```
+
+本地 `data/polyweather.db` 于 2026-05-27 的回测样本显示:
+
+| 版本 | 样本 | MAE | RMSE | Bias | 结算桶命中率 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| `deb_v1_raw` | 1272 | 1.626 | 2.602 | -0.633 | 25.8% |
+| `deb_v1_recent_bias_corrected` | 1272 | 1.499 | 2.542 | +0.263 | 31.4% |
+
+该结果只证明当前历史样本上的离线表现改善;后续仍需要持续用版本化回测追踪不同城市、季节与结算源下的漂移。
+
## 6. 前端展示
网页的模型展示读取:
@@ -228,6 +264,7 @@ raw current_forecasts
- `tests/test_multi_model_sources.py`
- `tests/test_deb_model_family.py`
+- `tests/test_deb_evaluation_upgrade.py`
重点覆盖:
@@ -237,3 +274,4 @@ raw current_forecasts
- DEB 家族去重
- 历史不足时的去重等权
- 有历史 MAE 时的去重动态权重
+- DEB raw/corrected 版本化评估、recent-bias correction、JSON/CSV 回测输出
diff --git a/frontend/.env.example b/frontend/.env.example
index 36c74808..0cea5e99 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -17,8 +17,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
-# 生产环境必须设为 https://polyweather-pro.vercel.app
-NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
+# 生产环境必须设为 https://polyweather.top
+NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
@@ -37,7 +37,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
-NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
+NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
diff --git a/frontend/.env.local b/frontend/.env.local
index 1d094089..e3feb295 100644
--- a/frontend/.env.local
+++ b/frontend/.env.local
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
-# 生产环境必须设为 https://polyweather-pro.vercel.app
+# 生产环境必须设为 https://polyweather.top
NEXT_PUBLIC_SITE_URL=https://polyweather.top
# 常用:前端鉴权开关
@@ -43,9 +43,9 @@ POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
-NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
+NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
-NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
+NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/polyyuanbot
diff --git a/frontend/.env.production b/frontend/.env.production
index 76036db1..5eadb7cd 100644
--- a/frontend/.env.production
+++ b/frontend/.env.production
@@ -3,3 +3,6 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_1z0DR7nZ1Juf_HGTASA8WA_uxcHJnby
NEXT_PUBLIC_SITE_URL=https://polyweather.top
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=https://api.polyweather.top
NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=false
+NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
+NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=8ce6332b57a42e606cf28224e82d1e02
+NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 01263524..703e676f 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -11,12 +11,18 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
+ARG NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
+ARG NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
+ARG NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL
ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
+ENV NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=$NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
+ENV NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=$NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL
+ENV NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=$NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS
WORKDIR /app/frontend
COPY --from=deps /app/frontend/node_modules ./node_modules
diff --git a/frontend/README.md b/frontend/README.md
index c2ea100a..e321e5f1 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -3,7 +3,7 @@
PolyWeather Pro 的生产前端工程。
线上地址:
-- [https://polyweather-pro.vercel.app/](https://polyweather-pro.vercel.app/)
+- [https://polyweather.top/](https://polyweather.top/)
## 技术栈
@@ -90,7 +90,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 钱包支付
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
-NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
+NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather.top,www.polyweather.top
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
# 社群入口
@@ -144,7 +144,7 @@ Ops:
当前前端已内置轻量管理页:
-- [https://polyweather-pro.vercel.app/ops](https://polyweather-pro.vercel.app/ops)
+- [https://polyweather.top/ops](https://polyweather.top/ops)
页面当前支持:
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index c19c03dc..28ecccfd 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -206,7 +206,12 @@ export interface ForecastData {
export interface DebForecast {
prediction: number | null;
+ raw_prediction?: number | null;
+ version?: string | null;
weights_info?: string | null;
+ bias_adjustment?: number | null;
+ bias_samples?: number | null;
+ intraday_adjustment?: number | null;
}
export interface CitySummary {
diff --git a/scripts/backtest_deb_versions.py b/scripts/backtest_deb_versions.py
new file mode 100644
index 00000000..78848c0a
--- /dev/null
+++ b/scripts/backtest_deb_versions.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Run versioned DEB backtests from runtime SQLite daily records."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.analysis.deb_evaluation import ( # noqa: E402
+ backtest_deb_versions,
+ flatten_daily_records,
+ write_backtest_report,
+)
+from src.database.runtime_state import DailyRecordRepository, RuntimeStateDB # noqa: E402
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--db", default=str(ROOT / "data" / "polyweather.db"))
+ parser.add_argument(
+ "--output-json",
+ default=str(ROOT / "data" / "deb_backtest_latest.json"),
+ )
+ parser.add_argument(
+ "--output-csv",
+ default=str(ROOT / "data" / "deb_backtest_latest.csv"),
+ )
+ parser.add_argument("--train-lookback-days", type=int, default=30)
+ parser.add_argument("--min-train-samples", type=int, default=2)
+ args = parser.parse_args()
+
+ db = RuntimeStateDB(args.db)
+ daily_records = DailyRecordRepository(db).load_all()
+ history = flatten_daily_records(daily_records)
+ report = backtest_deb_versions(
+ history,
+ train_lookback_days=args.train_lookback_days,
+ min_train_samples=args.min_train_samples,
+ )
+ write_backtest_report(
+ report,
+ json_path=args.output_json,
+ csv_path=args.output_csv,
+ )
+ print(
+ json.dumps(
+ {
+ "schema_version": report.get("schema_version"),
+ "versions": report.get("versions"),
+ "rows": len(report.get("rows") or []),
+ "output_json": args.output_json,
+ "output_csv": args.output_csv,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+ if not report.get("rows"):
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py
index 482237d5..bdd1a345 100644
--- a/src/analysis/deb_algorithm.py
+++ b/src/analysis/deb_algorithm.py
@@ -1209,6 +1209,82 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7, dec
return round(blended_high, 1), " | ".join(weight_str_parts)
+def calculate_deb_prediction(
+ city_name,
+ current_forecasts,
+ *,
+ lookback_days=7,
+ decay_factor=0.85,
+ bias_lookback_days=30,
+ bias_min_samples=3,
+ raw_calculator=None,
+):
+ """
+ Return the production DEB prediction with versioned recent-bias correction.
+
+ The raw DEB path remains `calculate_dynamic_weights`; this wrapper keeps the
+ raw value in the payload and only adds a conservative city-level signed-bias
+ adjustment when enough settled samples exist.
+ """
+ from src.analysis.deb_evaluation import (
+ DEB_RAW_VERSION,
+ DEB_RECENT_BIAS_CORRECTED_VERSION,
+ build_recent_bias_corrector,
+ flatten_daily_records,
+ )
+
+ calculate_raw = raw_calculator or calculate_dynamic_weights
+ raw_prediction, weights_info = calculate_raw(
+ city_name,
+ current_forecasts,
+ lookback_days=lookback_days,
+ decay_factor=decay_factor,
+ )
+ if raw_prediction is None:
+ return {
+ "prediction": None,
+ "raw_prediction": None,
+ "version": DEB_RAW_VERSION,
+ "weights_info": weights_info,
+ "bias_adjustment": 0.0,
+ "bias_samples": 0,
+ }
+
+ data = load_history(_get_history_file_path())
+ history_rows = flatten_daily_records(data)
+ corrected = build_recent_bias_corrector(
+ history_rows,
+ lookback_days=bias_lookback_days,
+ min_samples=bias_min_samples,
+ ).apply(city_name, raw_prediction)
+ bias_adjustment = float(corrected.get("bias_adjustment") or 0.0)
+ bias_samples = int(corrected.get("samples") or 0)
+ if bias_samples <= 0:
+ return {
+ "prediction": raw_prediction,
+ "raw_prediction": raw_prediction,
+ "version": DEB_RAW_VERSION,
+ "weights_info": weights_info,
+ "bias_adjustment": 0.0,
+ "bias_samples": 0,
+ }
+
+ next_weights_info = weights_info
+ if abs(bias_adjustment) >= 0.05:
+ next_weights_info = (
+ f"{weights_info or 'DEB'} | "
+ f"recent_bias({bias_adjustment:+.1f},n={bias_samples})"
+ )
+ return {
+ "prediction": corrected["corrected_prediction"],
+ "raw_prediction": corrected["raw_prediction"],
+ "version": DEB_RECENT_BIAS_CORRECTED_VERSION,
+ "weights_info": next_weights_info,
+ "bias_adjustment": bias_adjustment,
+ "bias_samples": bias_samples,
+ }
+
+
def get_deb_accuracy(city_name):
"""
计算 DEB 融合预测的历史准确率
diff --git a/src/analysis/deb_evaluation.py b/src/analysis/deb_evaluation.py
new file mode 100644
index 00000000..d4c8e441
--- /dev/null
+++ b/src/analysis/deb_evaluation.py
@@ -0,0 +1,319 @@
+from __future__ import annotations
+
+import csv
+import json
+import math
+import statistics
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+from src.analysis.settlement_rounding import apply_city_settlement
+
+DEB_RAW_VERSION = "deb_v1_raw"
+DEB_RECENT_BIAS_CORRECTED_VERSION = "deb_v1_recent_bias_corrected"
+DEB_BACKTEST_SCHEMA_VERSION = "deb_backtest_report.v1"
+
+
+def _sf(value: Any) -> float | None:
+ try:
+ if value is None or value == "":
+ return None
+ parsed = float(value)
+ return parsed if math.isfinite(parsed) else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _round3(value: float | None) -> float | None:
+ return None if value is None else round(float(value), 3)
+
+
+def _normalise_record(row: dict[str, Any]) -> dict[str, Any] | None:
+ city = str(row.get("city") or "").strip().lower()
+ target_date = str(row.get("target_date") or row.get("date") or "").strip()
+ prediction = _sf(row.get("prediction", row.get("deb_prediction")))
+ actual = _sf(row.get("actual", row.get("actual_high")))
+ if not city or not target_date or prediction is None or actual is None:
+ return None
+ return {
+ "city": city,
+ "target_date": target_date,
+ "prediction": prediction,
+ "actual": actual,
+ }
+
+
+def evaluate_prediction_records(
+ records: Iterable[dict[str, Any]],
+ *,
+ version: str,
+) -> dict[str, Any]:
+ rows = [row for record in records if (row := _normalise_record(record))]
+ if not rows:
+ return {
+ "version": version,
+ "samples": 0,
+ "mae": None,
+ "rmse": None,
+ "bias": None,
+ "bucket_hit_rate": None,
+ }
+
+ signed_errors = [row["prediction"] - row["actual"] for row in rows]
+ abs_errors = [abs(error) for error in signed_errors]
+ sq_errors = [error * error for error in signed_errors]
+ bucket_hits = 0
+ bucket_total = 0
+ for row in rows:
+ try:
+ pred_bucket = apply_city_settlement(row["city"], row["prediction"])
+ actual_bucket = apply_city_settlement(row["city"], row["actual"])
+ except Exception:
+ continue
+ if pred_bucket is None or actual_bucket is None:
+ continue
+ bucket_total += 1
+ if pred_bucket == actual_bucket:
+ bucket_hits += 1
+
+ return {
+ "version": version,
+ "samples": len(rows),
+ "mae": _round3(statistics.mean(abs_errors)),
+ "rmse": _round3(math.sqrt(statistics.mean(sq_errors))),
+ "bias": _round3(statistics.mean(signed_errors)),
+ "bucket_hit_rate": (
+ _round3(bucket_hits / bucket_total) if bucket_total else None
+ ),
+ }
+
+
+@dataclass(frozen=True)
+class BiasCorrectionResult:
+ version: str
+ raw_prediction: float
+ corrected_prediction: float
+ bias_adjustment: float
+ samples: int
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "version": self.version,
+ "raw_prediction": self.raw_prediction,
+ "corrected_prediction": self.corrected_prediction,
+ "bias_adjustment": self.bias_adjustment,
+ "samples": self.samples,
+ }
+
+
+class RecentBiasCorrector:
+ def __init__(self, bias_by_city: dict[str, tuple[float, int]]) -> None:
+ self._bias_by_city = bias_by_city
+
+ def apply(self, city: str, raw_prediction: float) -> dict[str, Any]:
+ city_key = str(city or "").strip().lower()
+ raw = float(raw_prediction)
+ bias, samples = self._bias_by_city.get(city_key, (0.0, 0))
+ adjustment = round(bias, 1)
+ return BiasCorrectionResult(
+ version=DEB_RECENT_BIAS_CORRECTED_VERSION,
+ raw_prediction=round(raw, 1),
+ corrected_prediction=round(raw + adjustment, 1),
+ bias_adjustment=adjustment,
+ samples=samples,
+ ).to_dict()
+
+
+def build_recent_bias_corrector(
+ history: Iterable[dict[str, Any]],
+ *,
+ lookback_days: int = 30,
+ min_samples: int = 3,
+ shrinkage_samples: int = 5,
+ max_adjustment: float = 3.0,
+) -> RecentBiasCorrector:
+ by_city: dict[str, list[dict[str, Any]]] = {}
+ for record in history:
+ row = _normalise_record(record)
+ if row is None:
+ continue
+ by_city.setdefault(row["city"], []).append(row)
+
+ bias_by_city: dict[str, tuple[float, int]] = {}
+ for city, rows in by_city.items():
+ rows.sort(key=lambda row: row["target_date"], reverse=True)
+ recent = rows[: max(int(lookback_days or 0), 1)]
+ signed_actual_minus_prediction = [
+ row["actual"] - row["prediction"] for row in recent
+ ]
+ samples = len(signed_actual_minus_prediction)
+ if samples < min_samples:
+ continue
+ raw_bias = statistics.mean(signed_actual_minus_prediction)
+ shrink = min(1.0, samples / max(float(shrinkage_samples), 1.0))
+ adjusted = raw_bias * shrink
+ adjusted = max(-abs(max_adjustment), min(abs(max_adjustment), adjusted))
+ bias_by_city[city] = (adjusted, samples)
+
+ return RecentBiasCorrector(bias_by_city)
+
+
+def backtest_deb_versions(
+ history: Iterable[dict[str, Any]],
+ *,
+ train_lookback_days: int = 30,
+ min_train_samples: int = 2,
+) -> dict[str, Any]:
+ rows = [row for record in history if (row := _normalise_record(record))]
+ rows.sort(key=lambda row: (row["city"], row["target_date"]))
+
+ report_rows: list[dict[str, Any]] = []
+ raw_eval_rows: list[dict[str, Any]] = []
+ corrected_eval_rows: list[dict[str, Any]] = []
+
+ by_city: dict[str, list[dict[str, Any]]] = {}
+ for row in rows:
+ previous = by_city.setdefault(row["city"], [])
+ if len(previous) >= min_train_samples:
+ corrector = build_recent_bias_corrector(
+ previous,
+ lookback_days=train_lookback_days,
+ min_samples=min_train_samples,
+ )
+ corrected = corrector.apply(row["city"], row["prediction"])
+ raw_prediction = round(row["prediction"], 1)
+ corrected_prediction = corrected["corrected_prediction"]
+
+ raw_eval_rows.append(
+ {
+ "city": row["city"],
+ "target_date": row["target_date"],
+ "prediction": raw_prediction,
+ "actual": row["actual"],
+ }
+ )
+ corrected_eval_rows.append(
+ {
+ "city": row["city"],
+ "target_date": row["target_date"],
+ "prediction": corrected_prediction,
+ "actual": row["actual"],
+ }
+ )
+ report_rows.append(
+ {
+ "city": row["city"],
+ "target_date": row["target_date"],
+ "actual": row["actual"],
+ "versions": {
+ DEB_RAW_VERSION: {
+ "prediction": raw_prediction,
+ "error": round(raw_prediction - row["actual"], 3),
+ },
+ DEB_RECENT_BIAS_CORRECTED_VERSION: {
+ "prediction": corrected_prediction,
+ "error": round(corrected_prediction - row["actual"], 3),
+ "bias_adjustment": corrected["bias_adjustment"],
+ "train_samples": corrected["samples"],
+ },
+ },
+ }
+ )
+ previous.append(row)
+
+ return {
+ "schema_version": DEB_BACKTEST_SCHEMA_VERSION,
+ "versions": {
+ DEB_RAW_VERSION: evaluate_prediction_records(
+ raw_eval_rows,
+ version=DEB_RAW_VERSION,
+ ),
+ DEB_RECENT_BIAS_CORRECTED_VERSION: evaluate_prediction_records(
+ corrected_eval_rows,
+ version=DEB_RECENT_BIAS_CORRECTED_VERSION,
+ ),
+ },
+ "rows": report_rows,
+ }
+
+
+def flatten_daily_records(
+ daily_records: dict[str, dict[str, dict[str, Any]]],
+) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ for city, by_date in (daily_records or {}).items():
+ if not isinstance(by_date, dict):
+ continue
+ for target_date, record in by_date.items():
+ if not isinstance(record, dict):
+ continue
+ rows.append(
+ {
+ "city": city,
+ "target_date": target_date,
+ "deb_prediction": record.get("deb_prediction"),
+ "actual_high": record.get("actual_high"),
+ }
+ )
+ return rows
+
+
+def write_backtest_report(
+ report: dict[str, Any],
+ *,
+ json_path: str | Path,
+ csv_path: str | Path | None = None,
+) -> None:
+ json_target = Path(json_path)
+ json_target.parent.mkdir(parents=True, exist_ok=True)
+ json_target.write_text(
+ json.dumps(report, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if csv_path is None:
+ return
+
+ csv_target = Path(csv_path)
+ csv_target.parent.mkdir(parents=True, exist_ok=True)
+ with csv_target.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(
+ handle,
+ fieldnames=[
+ "city",
+ "target_date",
+ "actual",
+ f"{DEB_RAW_VERSION}_prediction",
+ f"{DEB_RAW_VERSION}_error",
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_prediction",
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_error",
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_bias_adjustment",
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_train_samples",
+ ],
+ )
+ writer.writeheader()
+ for row in report.get("rows") or []:
+ versions = row.get("versions") or {}
+ raw = versions.get(DEB_RAW_VERSION) or {}
+ corrected = versions.get(DEB_RECENT_BIAS_CORRECTED_VERSION) or {}
+ writer.writerow(
+ {
+ "city": row.get("city"),
+ "target_date": row.get("target_date"),
+ "actual": row.get("actual"),
+ f"{DEB_RAW_VERSION}_prediction": raw.get("prediction"),
+ f"{DEB_RAW_VERSION}_error": raw.get("error"),
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_prediction": corrected.get(
+ "prediction"
+ ),
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_error": corrected.get(
+ "error"
+ ),
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_bias_adjustment": corrected.get(
+ "bias_adjustment"
+ ),
+ f"{DEB_RECENT_BIAS_CORRECTED_VERSION}_train_samples": corrected.get(
+ "train_samples"
+ ),
+ }
+ )
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index 9f7f3a49..c048e8cb 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -11,6 +11,7 @@ from typing import List, Optional, Tuple, Dict, Any
from src.analysis.deb_algorithm import (
calculate_dynamic_weights,
+ calculate_deb_prediction,
get_deb_accuracy,
update_daily_record,
_is_excluded_model_name,
@@ -313,22 +314,32 @@ def analyze_weather_trend(
# === DEB ===
deb_prediction = None
+ deb_raw_prediction = None
+ deb_version = None
+ deb_bias_adjustment = 0.0
+ deb_bias_samples = 0
deb_weights = ""
if city_name and current_forecasts:
- blended_high, weight_info = calculate_dynamic_weights(
- city_name, current_forecasts
+ deb_result = calculate_deb_prediction(
+ city_name,
+ current_forecasts,
+ raw_calculator=calculate_dynamic_weights,
)
- if blended_high is not None:
- deb_prediction = blended_high
- deb_weights = weight_info
+ if deb_result.get("prediction") is not None:
+ deb_prediction = deb_result.get("prediction")
+ deb_raw_prediction = deb_result.get("raw_prediction")
+ deb_version = deb_result.get("version")
+ deb_bias_adjustment = deb_result.get("bias_adjustment") or 0.0
+ deb_bias_samples = deb_result.get("bias_samples") or 0
+ deb_weights = deb_result.get("weights_info") or ""
insights.insert(
0,
- f"🧬 DEB 融合预测:{blended_high}{temp_symbol} ({weight_info})",
+ f"🧬 DEB 融合预测:{deb_prediction}{temp_symbol} ({deb_weights})",
)
ai_features.append(
- f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {blended_high}{temp_symbol}。"
+ f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {deb_prediction}{temp_symbol}。"
)
- _deb_to_save = blended_high
+ _deb_to_save = deb_prediction
# === METAR trend ===
recent_temps = metar.get("recent_temps", [])
@@ -417,7 +428,7 @@ def analyze_weather_trend(
# DEB blending uses the already-computed set of model forecasts
if ai_features and "DEB系统已通过历史偏差矫正算出期待点是" in ai_features[0]:
ai_features[0] = (
- f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {blended_high}{temp_symbol}。"
+ f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {deb_prediction}{temp_symbol}。"
)
if trend_direction == "stagnant":
@@ -825,6 +836,10 @@ def analyze_weather_trend(
"peak_status": peak_status,
"peak_hours": peak_hours,
"deb_prediction": deb_prediction,
+ "deb_raw_prediction": deb_raw_prediction,
+ "deb_version": deb_version,
+ "deb_bias_adjustment": deb_bias_adjustment,
+ "deb_bias_samples": deb_bias_samples,
"deb_weights": deb_weights,
"current_forecasts": current_forecasts,
"ens_data": ens_data,
diff --git a/tests/test_deb_evaluation_upgrade.py b/tests/test_deb_evaluation_upgrade.py
new file mode 100644
index 00000000..77a99090
--- /dev/null
+++ b/tests/test_deb_evaluation_upgrade.py
@@ -0,0 +1,121 @@
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+from src.analysis.deb_evaluation import (
+ DEB_RAW_VERSION,
+ DEB_RECENT_BIAS_CORRECTED_VERSION,
+ backtest_deb_versions,
+ build_recent_bias_corrector,
+ evaluate_prediction_records,
+ write_backtest_report,
+)
+from src.database.runtime_state import DailyRecordRepository, RuntimeStateDB
+
+
+def test_deb_evaluation_reports_mae_rmse_bias_and_bucket_hits():
+ records = [
+ {"city": "ankara", "target_date": "2026-05-20", "prediction": 20.0, "actual": 21.0},
+ {"city": "ankara", "target_date": "2026-05-21", "prediction": 22.0, "actual": 21.0},
+ {"city": "ankara", "target_date": "2026-05-22", "prediction": 23.0, "actual": 23.0},
+ ]
+
+ metrics = evaluate_prediction_records(records, version=DEB_RAW_VERSION)
+
+ assert metrics["version"] == DEB_RAW_VERSION
+ assert metrics["samples"] == 3
+ assert metrics["mae"] == 0.667
+ assert metrics["rmse"] == 0.816
+ assert metrics["bias"] == 0.0
+ assert metrics["bucket_hit_rate"] == 0.333
+
+
+def test_recent_bias_corrector_uses_signed_error_without_rewriting_raw_deb():
+ history = [
+ {"city": "ankara", "target_date": "2026-05-20", "deb_prediction": 20.0, "actual_high": 22.0},
+ {"city": "ankara", "target_date": "2026-05-21", "deb_prediction": 21.0, "actual_high": 23.0},
+ {"city": "ankara", "target_date": "2026-05-22", "deb_prediction": 24.0, "actual_high": 25.0},
+ ]
+
+ corrector = build_recent_bias_corrector(history, lookback_days=30, min_samples=2)
+ corrected = corrector.apply("ankara", raw_prediction=24.0)
+
+ assert corrected["version"] == DEB_RECENT_BIAS_CORRECTED_VERSION
+ assert corrected["raw_prediction"] == 24.0
+ assert corrected["corrected_prediction"] > corrected["raw_prediction"]
+ assert corrected["bias_adjustment"] == 1.0
+ assert corrected["samples"] == 3
+
+
+def test_backtest_deb_versions_compares_raw_and_bias_corrected_versions():
+ history = [
+ {"city": "ankara", "target_date": "2026-05-20", "deb_prediction": 20.0, "actual_high": 22.0},
+ {"city": "ankara", "target_date": "2026-05-21", "deb_prediction": 21.0, "actual_high": 23.0},
+ {"city": "ankara", "target_date": "2026-05-22", "deb_prediction": 24.0, "actual_high": 25.0},
+ {"city": "ankara", "target_date": "2026-05-23", "deb_prediction": 24.0, "actual_high": 26.0},
+ ]
+
+ report = backtest_deb_versions(history, train_lookback_days=30)
+
+ assert report["schema_version"] == "deb_backtest_report.v1"
+ assert report["versions"][DEB_RAW_VERSION]["samples"] == 2
+ assert report["versions"][DEB_RECENT_BIAS_CORRECTED_VERSION]["samples"] == 2
+ assert (
+ report["versions"][DEB_RECENT_BIAS_CORRECTED_VERSION]["mae"]
+ < report["versions"][DEB_RAW_VERSION]["mae"]
+ )
+ assert report["rows"][0]["versions"][DEB_RAW_VERSION]["prediction"] == 24.0
+ assert report["rows"][0]["versions"][DEB_RECENT_BIAS_CORRECTED_VERSION]["prediction"] == 24.8
+
+
+def test_write_backtest_report_persists_versioned_json_and_csv(tmp_path):
+ history = [
+ {"city": "ankara", "target_date": "2026-05-20", "deb_prediction": 20.0, "actual_high": 22.0},
+ {"city": "ankara", "target_date": "2026-05-21", "deb_prediction": 21.0, "actual_high": 23.0},
+ {"city": "ankara", "target_date": "2026-05-22", "deb_prediction": 24.0, "actual_high": 25.0},
+ ]
+ report = backtest_deb_versions(history)
+ json_path = tmp_path / "deb-backtest.json"
+ csv_path = tmp_path / "deb-backtest.csv"
+
+ write_backtest_report(report, json_path=json_path, csv_path=csv_path)
+
+ assert json_path.read_text(encoding="utf-8").startswith("{\n \"schema_version\": \"deb_backtest_report.v1\"")
+ csv_text = csv_path.read_text(encoding="utf-8")
+ assert "deb_v1_raw_prediction" in csv_text
+ assert "deb_v1_recent_bias_corrected_prediction" in csv_text
+
+
+def test_backtest_deb_versions_cli_reads_sqlite_and_writes_outputs(tmp_path):
+ db_path = tmp_path / "polyweather.db"
+ db = RuntimeStateDB(str(db_path))
+ repo = DailyRecordRepository(db)
+ repo.upsert_record("ankara", "2026-05-20", {"deb_prediction": 20.0, "actual_high": 22.0})
+ repo.upsert_record("ankara", "2026-05-21", {"deb_prediction": 21.0, "actual_high": 23.0})
+ repo.upsert_record("ankara", "2026-05-22", {"deb_prediction": 24.0, "actual_high": 25.0})
+ json_path = tmp_path / "report.json"
+ csv_path = tmp_path / "report.csv"
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ str(Path("scripts") / "backtest_deb_versions.py"),
+ "--db",
+ str(db_path),
+ "--output-json",
+ str(json_path),
+ "--output-csv",
+ str(csv_path),
+ ],
+ cwd=Path(__file__).resolve().parents[1],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(json_path.read_text(encoding="utf-8"))
+ assert payload["schema_version"] == "deb_backtest_report.v1"
+ assert payload["versions"][DEB_RAW_VERSION]["samples"] == 1
+ assert csv_path.exists()
diff --git a/tests/test_deb_model_family.py b/tests/test_deb_model_family.py
index e420a465..b372678b 100644
--- a/tests/test_deb_model_family.py
+++ b/tests/test_deb_model_family.py
@@ -1,5 +1,6 @@
from src.analysis.deb_algorithm import (
_collapse_forecasts_for_deb,
+ calculate_deb_prediction,
calculate_dynamic_weights,
)
@@ -91,6 +92,42 @@ def test_deb_weighted_path_uses_deduped_family_values(monkeypatch):
assert "家族去重" in info
+def test_calculate_deb_prediction_keeps_raw_and_adds_versioned_bias_correction(monkeypatch):
+ monkeypatch.setattr(
+ "src.analysis.deb_algorithm.load_history",
+ lambda _: {
+ "ankara": {
+ "2026-04-14": {
+ "actual_high": 22.0,
+ "deb_prediction": 20.0,
+ "forecasts": {"ECMWF": 20.0, "GFS": 20.0},
+ },
+ "2026-04-15": {
+ "actual_high": 23.0,
+ "deb_prediction": 21.0,
+ "forecasts": {"ECMWF": 21.0, "GFS": 21.0},
+ },
+ "2026-04-16": {
+ "actual_high": 25.0,
+ "deb_prediction": 24.0,
+ "forecasts": {"ECMWF": 24.0, "GFS": 24.0},
+ },
+ }
+ },
+ )
+
+ result = calculate_deb_prediction(
+ "ankara",
+ {"ECMWF": 24.0, "GFS": 24.0},
+ )
+
+ assert result["raw_prediction"] == 24.0
+ assert result["prediction"] == 25.0
+ assert result["version"] == "deb_v1_recent_bias_corrected"
+ assert result["bias_adjustment"] == 1.0
+ assert result["bias_samples"] == 3
+
+
def test_compute_hourly_model_errors_basic():
from src.analysis.deb_algorithm import compute_hourly_model_errors
diff --git a/web/analysis_service.py b/web/analysis_service.py
index 14280b2e..1de79c5e 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -24,7 +24,7 @@ from web.core import (
_sf,
_weather,
)
-from src.analysis.deb_algorithm import calculate_dynamic_weights
+from src.analysis.deb_algorithm import calculate_deb_prediction
from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.country_networks import build_country_network_snapshot
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
@@ -1082,11 +1082,18 @@ def _analyze(
# ── 6. DEB fusion ──
deb_val, deb_weights = None, ""
+ deb_raw_val, deb_version = None, None
+ deb_bias_adjustment, deb_bias_samples = 0.0, 0
+ deb_intraday_adjustment = 0.0
if current_forecasts:
- blended, winfo = calculate_dynamic_weights(city, current_forecasts)
- if blended is not None:
- deb_val = blended
- deb_weights = winfo
+ deb_result = calculate_deb_prediction(city, current_forecasts)
+ if deb_result.get("prediction") is not None:
+ deb_val = deb_result.get("prediction")
+ deb_raw_val = deb_result.get("raw_prediction")
+ deb_version = deb_result.get("version")
+ deb_bias_adjustment = deb_result.get("bias_adjustment") or 0.0
+ deb_bias_samples = deb_result.get("bias_samples") or 0
+ deb_weights = deb_result.get("weights_info") or ""
# ── 7. Ensemble stats ──
ens_data = {
@@ -1230,6 +1237,10 @@ def _analyze(
# Use shared DEB if not already set
if deb_val is None and sd.get("deb_prediction") is not None:
deb_val = sd["deb_prediction"]
+ deb_raw_val = sd.get("deb_raw_prediction") or deb_val
+ deb_version = sd.get("deb_version")
+ deb_bias_adjustment = sd.get("deb_bias_adjustment") or 0.0
+ deb_bias_samples = sd.get("deb_bias_samples") or 0
deb_weights = sd.get("deb_weights", "")
except Exception as e:
@@ -1288,10 +1299,11 @@ def _analyze(
max_correction_clamped = max(-max_correction, min(max_correction, max_so_far_excess * max(0.3, weight)))
blended_correction = hourly_correction * 0.6 + max_correction_clamped * 0.4
+ deb_intraday_adjustment = round(blended_correction, 1)
deb_val = round(deb_val + blended_correction, 1)
if mu is not None:
mu = round(mu + blended_correction, 1)
- deb_weights = f"{deb_weights or 'DEB'} + intraday_bias({blended_correction:+.1f})"
+ deb_weights = f"{deb_weights or 'DEB'} + intraday_bias({deb_intraday_adjustment:+.1f})"
# ── 12b. Next 48h hourly block for future-date analysis modal ──
next_48h_hourly = {
@@ -1490,6 +1502,10 @@ def _analyze(
if i == 0:
day_m = current_forecasts.copy()
d_val, d_winfo = deb_val, deb_weights
+ d_raw_val = deb_raw_val
+ d_version = deb_version
+ d_bias_adjustment = deb_bias_adjustment
+ d_bias_samples = deb_bias_samples
else:
day_m = mm_daily_raw.get(d_str, {}).copy()
if i < len(maxtemps) and maxtemps[i] is not None:
@@ -1505,14 +1521,20 @@ def _analyze(
}
d_val, d_winfo = None, ""
+ d_raw_val, d_version = None, None
+ d_bias_adjustment, d_bias_samples = 0.0, 0
d_probs = []
d_probs_all = []
if day_m:
try:
- blended, winfo = calculate_dynamic_weights(city, day_m)
- if blended is not None:
- d_val = blended
- d_winfo = winfo
+ deb_result = calculate_deb_prediction(city, day_m)
+ if deb_result.get("prediction") is not None:
+ d_val = deb_result.get("prediction")
+ d_raw_val = deb_result.get("raw_prediction")
+ d_version = deb_result.get("version")
+ d_bias_adjustment = deb_result.get("bias_adjustment") or 0.0
+ d_bias_samples = deb_result.get("bias_samples") or 0
+ d_winfo = deb_result.get("weights_info") or ""
# Calculate future probability based on model divergence
m_vals = [v for v in day_m.values() if v is not None]
@@ -1532,7 +1554,14 @@ def _analyze(
if day_m:
multi_model_daily[d_str] = {
"models": day_m,
- "deb": {"prediction": d_val, "weights_info": d_winfo},
+ "deb": {
+ "prediction": d_val,
+ "raw_prediction": d_raw_val,
+ "version": d_version,
+ "weights_info": d_winfo,
+ "bias_adjustment": d_bias_adjustment,
+ "bias_samples": d_bias_samples,
+ },
"probabilities": d_probs if i > 0 else probabilities, # Use today's real prob for today
"probabilities_all": d_probs_all if i > 0 else probabilities_all,
}
@@ -1689,7 +1718,15 @@ def _analyze(
"forecasts": {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},
+ "deb": {
+ "prediction": deb_val,
+ "raw_prediction": deb_raw_val,
+ "version": deb_version,
+ "weights_info": deb_weights,
+ "bias_adjustment": deb_bias_adjustment,
+ "bias_samples": deb_bias_samples,
+ "intraday_adjustment": deb_intraday_adjustment,
+ },
"deviation_monitor": deviation_monitor,
"ensemble": ens_data,
"probabilities": {
@@ -1998,10 +2035,18 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
}
deb_val = None
+ deb_raw_val = None
+ deb_version = None
+ deb_bias_adjustment = 0.0
+ deb_bias_samples = 0
if current_forecasts:
- blended, _weights_info = calculate_dynamic_weights(city, current_forecasts)
- if blended is not None:
- deb_val = blended
+ deb_result = calculate_deb_prediction(city, current_forecasts)
+ if deb_result.get("prediction") is not None:
+ deb_val = deb_result.get("prediction")
+ deb_raw_val = deb_result.get("raw_prediction")
+ deb_version = deb_result.get("version")
+ deb_bias_adjustment = deb_result.get("bias_adjustment") or 0.0
+ deb_bias_samples = deb_result.get("bias_samples") or 0
if deb_val is None:
deb_val = om_today
@@ -2071,7 +2116,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"obs_age_min": obs_age_min,
"observation_status": "live" if cur_temp is not None else "missing",
},
- "deb": {"prediction": _sf(deb_val)},
+ "deb": {
+ "prediction": _sf(deb_val),
+ "raw_prediction": _sf(deb_raw_val),
+ "version": deb_version,
+ "bias_adjustment": deb_bias_adjustment,
+ "bias_samples": deb_bias_samples,
+ },
"deviation_monitor": deviation_monitor or {},
"updated_at": datetime.now(timezone.utc).isoformat(),
}