From bbe3871b7ce80c97af90ef92577503a4572cdec7 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Sun, 7 Jun 2026 23:57:47 +0800
Subject: [PATCH] Expose DEB quality guidance
---
.../LiveTemperatureThresholdChart.tsx | 1 +
.../scan-terminal/TemperatureStatsBars.tsx | 50 ++++++++++
.../__tests__/temperatureStatsLabels.test.ts | 10 +-
.../scan-terminal/temperature-chart-logic.ts | 12 +++
frontend/lib/dashboard-types.ts | 12 +++
src/analysis/deb_algorithm.py | 95 ++++++++++++++++++-
src/analysis/trend_engine.py | 10 ++
tests/test_deb_model_family.py | 46 +++++++++
web/analysis_service.py | 31 ++++++
9 files changed, 264 insertions(+), 3 deletions(-)
diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx
index fb0043c0..3862dc19 100644
--- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx
+++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx
@@ -1213,6 +1213,7 @@ export function LiveTemperatureThresholdChart({
observedHighRunway={observedHighRunway}
wundergroundDailyHigh={wundergroundDailyHigh}
debVal={debVal}
+ debQuality={chartHourly?.debQuality || null}
modelMin={modelMin}
modelMax={modelMax}
spread={spread}
diff --git a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
index 1429a568..53e02919 100644
--- a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
+++ b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
@@ -38,6 +38,50 @@ function highLabel(label: string, isEn: boolean) {
return isEn ? (HIGH_LABEL_EN[label] || label) : label;
}
+type DebQuality = {
+ quality_tier?: string | null;
+ recommendation?: string | null;
+ recent_hit_rate?: number | null;
+ recent_samples?: number | null;
+};
+
+function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean) {
+ const recommendation = quality?.recommendation;
+ if (recommendation === "primary") return isEn ? "Primary" : "主用";
+ if (recommendation === "supporting") return isEn ? "Support" : "辅助";
+ if (recommendation === "context_only") return isEn ? "Context" : "参考";
+ if (recommendation === "insufficient") return isEn ? "Thin" : "样本少";
+ return "";
+}
+
+function debQualityClass(quality: DebQuality | null | undefined) {
+ const tier = quality?.quality_tier;
+ if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700";
+ if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
+ if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700";
+ return "border-slate-200 bg-slate-50 text-slate-500";
+}
+
+function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
+ const label = debQualityLabel(quality, isEn);
+ if (!label) return null;
+ const hitRate = quality?.recent_hit_rate;
+ const samples = quality?.recent_samples;
+ const titleParts = [
+ isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`,
+ hitRate == null ? null : `${hitRate.toFixed(0)}%`,
+ samples == null ? null : `n=${samples}`,
+ ].filter(Boolean);
+ return (
+
+ {label}
+
+ );
+}
+
function buildStatsLabels({
isEn,
isShenzhen,
@@ -82,6 +126,7 @@ export function TemperatureStatsBars({
observedHighRunway,
wundergroundDailyHigh,
debVal,
+ debQuality,
modelMin,
modelMax,
spread,
@@ -104,6 +149,7 @@ export function TemperatureStatsBars({
observedHighRunway: number | null;
wundergroundDailyHigh: number | null;
debVal: number | null;
+ debQuality?: DebQuality | null;
modelMin: number | null;
modelMax: number | null;
spread: number | null;
@@ -139,6 +185,7 @@ export function TemperatureStatsBars({
DEB: {temp(debVal, tempSymbol)}
+
{modelMin !== null && modelMax !== null && (
<>
@@ -187,6 +234,7 @@ export function TemperatureStatsBars({
DEB Max
+
{temp(debVal, tempSymbol)}
@@ -237,6 +285,7 @@ export function TemperatureStatsBars({
{temp(debVal, tempSymbol)}
+
@@ -263,3 +312,4 @@ export function TemperatureStatsBars({
}
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
+export const __buildDebQualityLabelForTest = debQualityLabel;
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
index a1c8e137..c9ca05dc 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
@@ -1,4 +1,4 @@
-import { __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
+import { __buildDebQualityLabelForTest, __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { temp } from "@/components/dashboard/scan-terminal/utils";
function assert(condition: unknown, message: string) {
@@ -63,4 +63,12 @@ export function runTests() {
assert(temp(null, "°C") === "--", "empty temperature values should not render as 0.0°C while city detail is loading");
assert(temp(undefined, "°C") === "--", "undefined temperature values should not render as 0.0°C while city detail is loading");
assert(temp("", "°C") === "--", "blank temperature values should not render as 0.0°C while city detail is loading");
+ assert(
+ __buildDebQualityLabelForTest({ recommendation: "context_only" }, true) === "Context",
+ "low-confidence DEB should render as context-only guidance in English",
+ );
+ assert(
+ __buildDebQualityLabelForTest({ recommendation: "insufficient" }, false) === "样本少",
+ "thin-sample DEB should render a Chinese low-sample label",
+ );
}
diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
index 3e4cadc3..2f184b62 100644
--- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
+++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
@@ -6,6 +6,7 @@ import type {
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
+ DebForecast,
DebHourlyPath,
ProbabilityBucket,
} from "@/lib/dashboard-types";
@@ -940,6 +941,7 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) {
type HourlyForecast = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
+ debQuality?: Pick | null;
debHourlyPath?: DebHourlyPath | null;
localDate?: string | null;
localTime?: string | null;
@@ -967,6 +969,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
return {
forecastTodayHigh: null,
debPrediction: validNumber(row.deb_prediction),
+ debQuality: null,
debHourlyPath: null,
localDate: row.local_date || null,
localTime: row.local_time || null,
@@ -1029,6 +1032,14 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
return {
forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
+ debQuality: json.deb ? {
+ quality_tier: json.deb.quality_tier,
+ recommendation: json.deb.recommendation,
+ recent_hit_rate: json.deb.recent_hit_rate,
+ recent_samples: json.deb.recent_samples,
+ recent_hits: json.deb.recent_hits,
+ recent_mae: json.deb.recent_mae,
+ } : null,
debHourlyPath: json.deb?.hourly_path || null,
localDate: json.local_date || (json as any)?.overview?.local_date || null,
localTime: json.local_time || null,
@@ -1366,6 +1377,7 @@ function mergePatchIntoHourly(
...(prev || {
forecastTodayHigh: null,
debPrediction: null,
+ debQuality: null,
localDate: null,
localTime: null,
times: [],
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index add7a983..608420cc 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -228,6 +228,12 @@ export interface DebForecast {
weights_info?: string | null;
bias_adjustment?: number | null;
bias_samples?: number | null;
+ quality_tier?: string | null;
+ recommendation?: string | null;
+ recent_hit_rate?: number | null;
+ recent_samples?: number | null;
+ recent_hits?: number | null;
+ recent_mae?: number | null;
intraday_adjustment?: number | null;
hourly_path?: DebHourlyPath | null;
hourly_correction?: Record | null;
@@ -328,6 +334,12 @@ export interface DailyModelForecast {
models?: Record;
deb?: {
prediction?: number | null;
+ quality_tier?: string | null;
+ recommendation?: string | null;
+ recent_hit_rate?: number | null;
+ recent_samples?: number | null;
+ recent_hits?: number | null;
+ recent_mae?: number | null;
};
probabilities?: ProbabilityBucket[];
probabilities_all?: ProbabilityBucket[];
diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py
index 76c0f342..0698df9e 100644
--- a/src/analysis/deb_algorithm.py
+++ b/src/analysis/deb_algorithm.py
@@ -1249,6 +1249,79 @@ def calculate_dynamic_weight_components(
}
+def _assess_recent_deb_quality(
+ city_name,
+ history_rows,
+ *,
+ adjustment=0.0,
+ lookback_days=30,
+):
+ city_key = str(city_name or "").strip().lower()
+ rows = [
+ row
+ for row in (history_rows or [])
+ if str(row.get("city") or "").strip().lower() == city_key
+ ]
+ rows.sort(key=lambda row: str(row.get("target_date") or ""), reverse=True)
+ recent = rows[: max(int(lookback_days or 0), 1)]
+ hits = 0
+ samples = 0
+ errors = []
+ for row in recent:
+ prediction = _sf(row.get("prediction", row.get("deb_prediction")))
+ actual = _sf(row.get("actual", row.get("actual_high")))
+ if prediction is None or actual is None:
+ continue
+ effective_prediction = prediction + float(adjustment or 0.0)
+ try:
+ pred_bucket = apply_city_settlement(city_key, effective_prediction)
+ actual_bucket = apply_city_settlement(city_key, actual)
+ except Exception:
+ continue
+ if pred_bucket is None or actual_bucket is None:
+ continue
+ samples += 1
+ if pred_bucket == actual_bucket:
+ hits += 1
+ errors.append(abs(effective_prediction - actual))
+
+ hit_rate = (hits / samples * 100.0) if samples else None
+ mae = (sum(errors) / len(errors)) if errors else None
+ if samples < 3 or hit_rate is None or mae is None:
+ tier = "insufficient"
+ recommendation = "insufficient"
+ elif hit_rate >= 67.0 and mae <= 1.25:
+ tier = "high"
+ recommendation = "primary"
+ elif hit_rate >= 34.0 and mae <= 1.75:
+ tier = "medium"
+ recommendation = "supporting"
+ else:
+ tier = "low"
+ recommendation = "context_only"
+
+ return {
+ "quality_tier": tier,
+ "recommendation": recommendation,
+ "recent_hit_rate": round(hit_rate, 1) if hit_rate is not None else None,
+ "recent_samples": samples,
+ "recent_hits": hits,
+ "recent_mae": round(mae, 2) if mae is not None else None,
+ }
+
+
+def _append_deb_quality_note(weights_info, quality):
+ tier = (quality or {}).get("quality_tier")
+ if tier not in {"low", "insufficient"}:
+ return weights_info
+ note = f"quality:{tier}"
+ if weights_info:
+ if note in weights_info:
+ return weights_info
+ return f"{weights_info} | {note}"
+ return note
+
+
def calculate_deb_prediction(
city_name,
current_forecasts,
@@ -1283,13 +1356,22 @@ def calculate_deb_prediction(
decay_factor=decay_factor,
)
if raw_prediction is None:
+ quality = {
+ "quality_tier": "insufficient",
+ "recommendation": "insufficient",
+ "recent_hit_rate": None,
+ "recent_samples": 0,
+ "recent_hits": 0,
+ "recent_mae": None,
+ }
return {
"prediction": None,
"raw_prediction": None,
"version": DEB_RAW_VERSION,
- "weights_info": weights_info,
+ "weights_info": _append_deb_quality_note(weights_info, quality),
"bias_adjustment": 0.0,
"bias_samples": 0,
+ **quality,
}
data = load_history(_get_history_file_path())
@@ -1309,14 +1391,21 @@ def calculate_deb_prediction(
bias_adjustment = float(corrected.get("bias_adjustment") or 0.0)
bias_samples = int(corrected.get("samples") or 0)
+ quality = _assess_recent_deb_quality(
+ city_name,
+ history_rows,
+ adjustment=bias_adjustment,
+ lookback_days=bias_lookback_days,
+ )
if bias_samples <= 0:
return {
"prediction": raw_prediction,
"raw_prediction": raw_prediction,
"version": DEB_RAW_VERSION,
- "weights_info": weights_info,
+ "weights_info": _append_deb_quality_note(weights_info, quality),
"bias_adjustment": 0.0,
"bias_samples": 0,
+ **quality,
}
next_weights_info = weights_info
@@ -1330,6 +1419,7 @@ def calculate_deb_prediction(
f"{weights_info or 'DEB'} | "
f"{correction_label}({bias_adjustment:+.1f},n={bias_samples})"
)
+ next_weights_info = _append_deb_quality_note(next_weights_info, quality)
return {
"prediction": corrected["corrected_prediction"],
"raw_prediction": corrected["raw_prediction"],
@@ -1337,6 +1427,7 @@ def calculate_deb_prediction(
"weights_info": next_weights_info,
"bias_adjustment": bias_adjustment,
"bias_samples": bias_samples,
+ **quality,
}
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index 1404fa1e..673bc7d0 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -449,6 +449,7 @@ def analyze_weather_trend(
deb_bias_adjustment = 0.0
deb_bias_samples = 0
deb_weights = ""
+ deb_quality = {}
if city_name and current_forecasts:
deb_result = calculate_deb_prediction(
city_name,
@@ -462,6 +463,14 @@ def analyze_weather_trend(
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 ""
+ deb_quality = {
+ "quality_tier": deb_result.get("quality_tier"),
+ "recommendation": deb_result.get("recommendation"),
+ "recent_hit_rate": deb_result.get("recent_hit_rate"),
+ "recent_samples": deb_result.get("recent_samples"),
+ "recent_hits": deb_result.get("recent_hits"),
+ "recent_mae": deb_result.get("recent_mae"),
+ }
insights.insert(
0,
f"🧬 DEB 融合预测:{deb_prediction}{temp_symbol} ({deb_weights})",
@@ -992,6 +1001,7 @@ def analyze_weather_trend(
"deb_bias_adjustment": deb_bias_adjustment,
"deb_bias_samples": deb_bias_samples,
"deb_weights": deb_weights,
+ "deb_quality": deb_quality,
"current_forecasts": current_forecasts,
"ens_data": ens_data,
"forecast_miss_deg": forecast_miss_deg,
diff --git a/tests/test_deb_model_family.py b/tests/test_deb_model_family.py
index 79120961..28821aaa 100644
--- a/tests/test_deb_model_family.py
+++ b/tests/test_deb_model_family.py
@@ -174,6 +174,52 @@ def test_calculate_deb_prediction_prefers_bucket_calibration_when_enough_samples
assert result["bias_samples"] == 5
+def test_calculate_deb_prediction_reports_recent_quality_for_effective_deb(monkeypatch):
+ monkeypatch.setattr(
+ "src.analysis.deb_algorithm.load_history",
+ lambda _: {
+ "high": {
+ "2026-04-11": {"actual_high": 21.0, "deb_prediction": 21.0},
+ "2026-04-12": {"actual_high": 22.0, "deb_prediction": 22.0},
+ "2026-04-13": {"actual_high": 23.0, "deb_prediction": 23.0},
+ "2026-04-14": {"actual_high": 24.0, "deb_prediction": 24.0},
+ "2026-04-15": {"actual_high": 25.0, "deb_prediction": 25.0},
+ },
+ "low": {
+ "2026-04-11": {"actual_high": 20.0, "deb_prediction": 16.0},
+ "2026-04-12": {"actual_high": 21.0, "deb_prediction": 26.0},
+ "2026-04-13": {"actual_high": 22.0, "deb_prediction": 18.0},
+ "2026-04-14": {"actual_high": 23.0, "deb_prediction": 28.0},
+ "2026-04-15": {"actual_high": 24.0, "deb_prediction": 20.0},
+ },
+ "thin": {
+ "2026-04-14": {"actual_high": 23.0, "deb_prediction": 23.0},
+ "2026-04-15": {"actual_high": 24.0, "deb_prediction": 24.0},
+ },
+ },
+ )
+
+ def raw(_city, _forecasts, **_kwargs):
+ return 25.0, "raw"
+
+ high = calculate_deb_prediction("high", {"ECMWF": 25.0}, raw_calculator=raw)
+ low = calculate_deb_prediction("low", {"ECMWF": 25.0}, raw_calculator=raw)
+ thin = calculate_deb_prediction("thin", {"ECMWF": 25.0}, raw_calculator=raw)
+
+ assert high["quality_tier"] == "high"
+ assert high["recommendation"] == "primary"
+ assert high["recent_hit_rate"] == 100.0
+
+ assert low["quality_tier"] == "low"
+ assert low["recommendation"] == "context_only"
+ assert low["recent_hit_rate"] == 0.0
+ assert "quality:low" in low["weights_info"]
+
+ assert thin["quality_tier"] == "insufficient"
+ assert thin["recommendation"] == "insufficient"
+ assert thin["recent_samples"] == 2
+
+
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 9608d74a..db9b325f 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -1198,6 +1198,7 @@ def _analyze(
deb_bias_adjustment, deb_bias_samples = 0.0, 0
deb_intraday_adjustment = 0.0
deb_hourly_consensus = None
+ deb_quality = {}
if current_forecasts:
deb_result = calculate_deb_prediction(city, current_forecasts)
if deb_result.get("prediction") is not None:
@@ -1207,6 +1208,14 @@ def _analyze(
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 ""
+ deb_quality = {
+ "quality_tier": deb_result.get("quality_tier"),
+ "recommendation": deb_result.get("recommendation"),
+ "recent_hit_rate": deb_result.get("recent_hit_rate"),
+ "recent_samples": deb_result.get("recent_samples"),
+ "recent_hits": deb_result.get("recent_hits"),
+ "recent_mae": deb_result.get("recent_mae"),
+ }
deb_hourly_consensus = build_deb_hourly_consensus_path(
city=city,
hourly_times=mm.get("hourly_times") or [],
@@ -1366,6 +1375,7 @@ def _analyze(
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", "")
+ deb_quality = sd.get("deb_quality") or deb_quality
if deb_hourly_consensus is None and sd.get("deb_hourly_consensus"):
deb_hourly_consensus = sd.get("deb_hourly_consensus")
@@ -1675,6 +1685,7 @@ def _analyze(
d_val, d_winfo = None, ""
d_raw_val, d_version = None, None
d_bias_adjustment, d_bias_samples = 0.0, 0
+ d_quality = {}
d_probs = []
d_probs_all = []
if day_m:
@@ -1688,6 +1699,14 @@ def _analyze(
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 ""
+ d_quality = {
+ "quality_tier": deb_result.get("quality_tier"),
+ "recommendation": deb_result.get("recommendation"),
+ "recent_hit_rate": deb_result.get("recent_hit_rate"),
+ "recent_samples": deb_result.get("recent_samples"),
+ "recent_hits": deb_result.get("recent_hits"),
+ "recent_mae": deb_result.get("recent_mae"),
+ }
# Calculate future probability based on model divergence
m_vals = [v for v in day_m.values() if v is not None]
@@ -1714,6 +1733,7 @@ def _analyze(
"weights_info": d_winfo,
"bias_adjustment": d_bias_adjustment,
"bias_samples": d_bias_samples,
+ **d_quality,
},
"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,
@@ -1880,6 +1900,7 @@ def _analyze(
"hourly_consensus": deb_hourly_consensus,
"hourly_path": deb_hourly_path,
"hourly_correction": (deb_hourly_path or {}).get("correction") if isinstance(deb_hourly_path, dict) else None,
+ **deb_quality,
},
"deviation_monitor": deviation_monitor,
"ensemble": ens_data,
@@ -2201,6 +2222,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
deb_version = None
deb_bias_adjustment = 0.0
deb_bias_samples = 0
+ deb_quality = {}
if current_forecasts:
deb_result = calculate_deb_prediction(city, current_forecasts)
if deb_result.get("prediction") is not None:
@@ -2209,6 +2231,14 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
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_quality = {
+ "quality_tier": deb_result.get("quality_tier"),
+ "recommendation": deb_result.get("recommendation"),
+ "recent_hit_rate": deb_result.get("recent_hit_rate"),
+ "recent_samples": deb_result.get("recent_samples"),
+ "recent_hits": deb_result.get("recent_hits"),
+ "recent_mae": deb_result.get("recent_mae"),
+ }
if deb_val is None:
deb_val = om_today
@@ -2285,6 +2315,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"version": deb_version,
"bias_adjustment": deb_bias_adjustment,
"bias_samples": deb_bias_samples,
+ **deb_quality,
},
"deviation_monitor": deviation_monitor or {},
"updated_at": datetime.now(timezone.utc).isoformat(),