diff --git a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
index 53e02919..a5082911 100644
--- a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
+++ b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx
@@ -43,6 +43,19 @@ type DebQuality = {
recommendation?: string | null;
recent_hit_rate?: number | null;
recent_samples?: number | null;
+ ensemble_signal?: DebEnsembleSignal | null;
+};
+
+type DebEnsembleSignal = {
+ available?: boolean;
+ stance?: string | null;
+ label_zh?: string | null;
+ label_en?: string | null;
+ reason_zh?: string | null;
+ reason_en?: string | null;
+ spread?: number | null;
+ deb_distance?: number | null;
+ confidence_delta?: number | null;
};
function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean) {
@@ -55,6 +68,9 @@ function debQualityLabel(quality: DebQuality | null | undefined, isEn: boolean)
}
function debQualityClass(quality: DebQuality | null | undefined) {
+ const stance = quality?.ensemble_signal?.available ? quality.ensemble_signal.stance : null;
+ if (stance === "caution") return "border-amber-300 bg-amber-50 text-amber-700";
+ if (stance === "supporting") return "border-emerald-200 bg-emerald-50 text-emerald-700";
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";
@@ -62,22 +78,46 @@ function debQualityClass(quality: DebQuality | null | undefined) {
return "border-slate-200 bg-slate-50 text-slate-500";
}
-function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
+function debEnsembleShortLabel(signal: DebEnsembleSignal | null | undefined, isEn: boolean) {
+ if (!signal?.available) return "";
+ if (signal.stance === "supporting") return isEn ? "Ens+" : "集+";
+ if (signal.stance === "caution") return isEn ? "Ens!" : "集警";
+ return "";
+}
+
+function debQualityTitle(quality: DebQuality | null | undefined, isEn: boolean) {
const label = debQualityLabel(quality, isEn);
- if (!label) return null;
const hitRate = quality?.recent_hit_rate;
const samples = quality?.recent_samples;
+ const ensemble = quality?.ensemble_signal;
const titleParts = [
- isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`,
+ label ? (isEn ? `DEB recommendation: ${label}` : `DEB 建议:${label}`) : null,
hitRate == null ? null : `${hitRate.toFixed(0)}%`,
samples == null ? null : `n=${samples}`,
+ ensemble?.available
+ ? `${isEn ? ensemble.label_en || "Ensemble" : ensemble.label_zh || "集合"}: ${
+ isEn ? ensemble.reason_en || "" : ensemble.reason_zh || ""
+ }`
+ : null,
].filter(Boolean);
+ return titleParts.join(" · ");
+}
+
+function DebQualityBadge({ quality, isEn }: { quality?: DebQuality | null; isEn: boolean }) {
+ const label = debQualityLabel(quality, isEn);
+ const ensembleLabel = debEnsembleShortLabel(quality?.ensemble_signal, isEn);
+ if (!label && !ensembleLabel) return null;
return (
- {label}
+ {label || "DEB"}
+ {ensembleLabel && (
+
+ {ensembleLabel}
+
+ )}
);
}
@@ -313,3 +353,6 @@ export function TemperatureStatsBars({
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
export const __buildDebQualityLabelForTest = debQualityLabel;
+export const __buildDebQualityClassForTest = debQualityClass;
+export const __buildDebQualityTitleForTest = debQualityTitle;
+export const __buildDebEnsembleLabelForTest = debEnsembleShortLabel;
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
index c9ca05dc..27c0ae48 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureStatsLabels.test.ts
@@ -1,4 +1,10 @@
-import { __buildDebQualityLabelForTest, __buildTemperatureStatsLabelsForTest } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
+import {
+ __buildDebEnsembleLabelForTest,
+ __buildDebQualityClassForTest,
+ __buildDebQualityLabelForTest,
+ __buildDebQualityTitleForTest,
+ __buildTemperatureStatsLabelsForTest,
+} from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
import { temp } from "@/components/dashboard/scan-terminal/utils";
function assert(condition: unknown, message: string) {
@@ -71,4 +77,35 @@ export function runTests() {
__buildDebQualityLabelForTest({ recommendation: "insufficient" }, false) === "样本少",
"thin-sample DEB should render a Chinese low-sample label",
);
+ const supportingSignal = {
+ available: true,
+ stance: "supporting",
+ label_zh: "集合支撑",
+ label_en: "Ensemble support",
+ reason_zh: "集合区间较窄",
+ reason_en: "Ensemble spread is tight",
+ };
+ assert(
+ __buildDebEnsembleLabelForTest(supportingSignal, false) === "集+",
+ "supporting ensemble signal should render a compact Chinese marker",
+ );
+ assert(
+ __buildDebEnsembleLabelForTest({ ...supportingSignal, stance: "caution" }, true) === "Ens!",
+ "caution ensemble signal should render a compact English marker",
+ );
+ assert(
+ __buildDebQualityClassForTest({
+ recommendation: "primary",
+ quality_tier: "high",
+ ensemble_signal: { ...supportingSignal, stance: "caution" },
+ }).includes("amber"),
+ "ensemble caution should override the DEB quality badge color",
+ );
+ assert(
+ __buildDebQualityTitleForTest(
+ { recommendation: "primary", ensemble_signal: supportingSignal },
+ false,
+ ).includes("集合支撑"),
+ "DEB badge title should include the ensemble signal reason",
+ );
}
diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
index c0e866c8..42e11671 100644
--- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
+++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts
@@ -1605,7 +1605,16 @@ function seedRunwayPlateHistoryFromRow(
type ChartRenderState = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
- debQuality?: Pick | null;
+ debQuality?: Pick<
+ DebForecast,
+ | "quality_tier"
+ | "recommendation"
+ | "recent_hit_rate"
+ | "recent_samples"
+ | "recent_hits"
+ | "recent_mae"
+ | "ensemble_signal"
+ > | null;
debHourlyPath?: DebHourlyPath | null;
localDate?: string | null;
localTime?: string | null;
@@ -1995,6 +2004,7 @@ function parseFullChartDetailFromCityDetail(json: CityDetail | null): FullChartD
recent_samples: json.deb.recent_samples,
recent_hits: json.deb.recent_hits,
recent_mae: json.deb.recent_mae,
+ ensemble_signal: json.deb.ensemble_signal,
} : null,
debHourlyPath: json.deb?.hourly_path || null,
localDate: json.local_date || (json as any)?.overview?.local_date || null,
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index a5d740be..38beb91c 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -221,6 +221,21 @@ export interface DebHourlyPath {
correction?: Record | null;
}
+export interface DebEnsembleSignal {
+ available?: boolean;
+ stance?: "supporting" | "neutral" | "caution" | "unavailable" | string;
+ confidence_delta?: number | null;
+ median?: number | null;
+ p10?: number | null;
+ p90?: number | null;
+ spread?: number | null;
+ deb_distance?: number | null;
+ label_zh?: string | null;
+ label_en?: string | null;
+ reason_zh?: string | null;
+ reason_en?: string | null;
+}
+
export interface DebForecast {
prediction: number | null;
raw_prediction?: number | null;
@@ -239,6 +254,7 @@ export interface DebForecast {
intraday_adjustment?: number | null;
hourly_path?: DebHourlyPath | null;
hourly_correction?: Record | null;
+ ensemble_signal?: DebEnsembleSignal | null;
}
export interface CitySummary {
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index 0371c1f1..2e2b8af1 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -59,6 +59,116 @@ def _median(values: List[float]) -> Optional[float]:
return (sorted_values[mid - 1] + sorted_values[mid]) / 2.0
+def _build_deb_ensemble_signal(
+ *,
+ deb_prediction: Optional[float],
+ ens_median: Optional[float],
+ ens_p10: Optional[float],
+ ens_p90: Optional[float],
+ temp_symbol: str,
+) -> Dict[str, Any]:
+ unavailable = {
+ "available": False,
+ "stance": "unavailable",
+ "confidence_delta": 0.0,
+ "median": ens_median,
+ "p10": ens_p10,
+ "p90": ens_p90,
+ "spread": None,
+ "deb_distance": None,
+ "label_zh": "集合缺失",
+ "label_en": "No ensemble",
+ "reason_zh": "集合预报数据不完整,DEB 不做 ensemble 置信度校验。",
+ "reason_en": "Ensemble data is incomplete, so DEB confidence is not ensemble-checked.",
+ }
+ if (
+ deb_prediction is None
+ or ens_median is None
+ or ens_p10 is None
+ or ens_p90 is None
+ ):
+ return unavailable
+
+ low = min(ens_p10, ens_p90)
+ high = max(ens_p10, ens_p90)
+ spread = high - low
+ deb_distance = abs(deb_prediction - ens_median)
+ scale = 1.8 if "F" in str(temp_symbol).upper() else 1.0
+ narrow_spread = 1.5 * scale
+ wide_spread = 3.5 * scale
+ aligned_gap = 0.7 * scale
+ divergent_gap = 1.5 * scale
+
+ rounded_spread = round(spread, 1)
+ rounded_distance = round(deb_distance, 1)
+ unit = temp_symbol or "°"
+
+ if spread >= wide_spread or deb_distance >= max(divergent_gap, spread * 0.45):
+ return {
+ "available": True,
+ "stance": "caution",
+ "confidence_delta": -0.12,
+ "median": round(ens_median, 1),
+ "p10": round(low, 1),
+ "p90": round(high, 1),
+ "spread": rounded_spread,
+ "deb_distance": rounded_distance,
+ "label_zh": "集合分歧",
+ "label_en": "Ensemble caution",
+ "reason_zh": (
+ f"集合区间宽度 {rounded_spread}{unit},DEB 距集合中位数 "
+ f"{rounded_distance}{unit},该点位应降低置信度。"
+ ),
+ "reason_en": (
+ f"Ensemble spread is {rounded_spread}{unit}; DEB is "
+ f"{rounded_distance}{unit} from the ensemble median, so confidence is reduced."
+ ),
+ }
+
+ if spread <= narrow_spread and deb_distance <= aligned_gap:
+ return {
+ "available": True,
+ "stance": "supporting",
+ "confidence_delta": 0.08,
+ "median": round(ens_median, 1),
+ "p10": round(low, 1),
+ "p90": round(high, 1),
+ "spread": rounded_spread,
+ "deb_distance": rounded_distance,
+ "label_zh": "集合支撑",
+ "label_en": "Ensemble support",
+ "reason_zh": (
+ f"集合区间较窄,DEB 仅距集合中位数 {rounded_distance}{unit},"
+ "可作为置信度加分。"
+ ),
+ "reason_en": (
+ f"Ensemble spread is tight and DEB is only {rounded_distance}{unit} "
+ "from the ensemble median, adding confidence."
+ ),
+ }
+
+ return {
+ "available": True,
+ "stance": "neutral",
+ "confidence_delta": 0.0,
+ "median": round(ens_median, 1),
+ "p10": round(low, 1),
+ "p90": round(high, 1),
+ "spread": rounded_spread,
+ "deb_distance": rounded_distance,
+ "label_zh": "集合中性",
+ "label_en": "Ensemble neutral",
+ "reason_zh": (
+ f"集合区间宽度 {rounded_spread}{unit},DEB 距集合中位数 "
+ f"{rounded_distance}{unit},暂不调整置信度。"
+ ),
+ "reason_en": (
+ f"Ensemble spread is {rounded_spread}{unit}; DEB is "
+ f"{rounded_distance}{unit} from the median, so confidence is unchanged."
+ ),
+ }
+
+
def _peak_hours_from_hourly_values(
hourly_values: List[Tuple[str, float]],
*,
@@ -636,6 +746,13 @@ def analyze_weather_trend(
ens_p90 = _sf(ensemble.get("p90"))
ens_median = _sf(ensemble.get("median"))
ens_data = {"p10": ens_p10, "p90": ens_p90, "median": ens_median}
+ deb_ensemble_signal = _build_deb_ensemble_signal(
+ deb_prediction=deb_prediction,
+ ens_median=ens_median,
+ ens_p10=ens_p10,
+ ens_p90=ens_p90,
+ temp_symbol=temp_symbol,
+ )
sigma = None
fallback_sigma = False
@@ -648,6 +765,14 @@ def analyze_weather_trend(
if not is_cooling:
insights.append(msg1)
ai_features.append(msg1)
+ if deb_ensemble_signal.get("available") and deb_prediction is not None:
+ ensemble_deb_msg = (
+ f"🧬 {deb_ensemble_signal['label_zh']}: "
+ f"{deb_ensemble_signal['reason_zh']}"
+ )
+ ai_features.append(ensemble_deb_msg)
+ if deb_ensemble_signal.get("stance") == "caution":
+ insights.append(ensemble_deb_msg)
if om_today is not None:
if om_today > ens_p90 and (
@@ -1008,6 +1133,7 @@ def analyze_weather_trend(
"deb_bias_samples": deb_bias_samples,
"deb_weights": deb_weights,
"deb_quality": deb_quality,
+ "deb_ensemble_signal": deb_ensemble_signal,
"current_forecasts": current_forecasts,
"ens_data": ens_data,
"forecast_miss_deg": forecast_miss_deg,
diff --git a/tests/test_trend_engine.py b/tests/test_trend_engine.py
index 8ba01deb..592aec99 100644
--- a/tests/test_trend_engine.py
+++ b/tests/test_trend_engine.py
@@ -242,6 +242,72 @@ class TestMuCalculation:
assert sd["peak_status"] == "before"
+class TestDebEnsembleSignal:
+ @patch(
+ "src.analysis.trend_engine.calculate_deb_prediction",
+ return_value={
+ "prediction": 30.1,
+ "raw_prediction": 30.1,
+ "weights_info": "test weights",
+ },
+ )
+ @patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
+ @patch("src.analysis.trend_engine.update_daily_record")
+ def test_narrow_ensemble_supports_aligned_deb(self, _udr, _deb_acc, _deb):
+ data = _make_weather_data(
+ cur_temp=25.0,
+ max_so_far=25.2,
+ om_today_high=30.0,
+ ens_median=30.0,
+ ens_p10=29.4,
+ ens_p90=30.6,
+ local_time="2026-03-04 10:00",
+ multi_model={"ECMWF": 30.2, "GFS": 30.0},
+ )
+
+ _, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
+
+ signal = sd["deb_ensemble_signal"]
+ assert signal["available"] is True
+ assert signal["stance"] == "supporting"
+ assert signal["confidence_delta"] > 0
+ assert signal["spread"] == 1.2
+ assert "集合支撑" in ai_context
+ assert "GEFS" not in sd["current_forecasts"]
+ assert not any("ensemble" in name.lower() for name in sd["current_forecasts"])
+
+ @patch(
+ "src.analysis.trend_engine.calculate_deb_prediction",
+ return_value={
+ "prediction": 31.0,
+ "raw_prediction": 31.0,
+ "weights_info": "test weights",
+ },
+ )
+ @patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
+ @patch("src.analysis.trend_engine.update_daily_record")
+ def test_wide_ensemble_marks_deb_as_caution(self, _udr, _deb_acc, _deb):
+ data = _make_weather_data(
+ cur_temp=25.0,
+ max_so_far=25.2,
+ om_today_high=30.0,
+ ens_median=29.0,
+ ens_p10=24.0,
+ ens_p90=34.0,
+ local_time="2026-03-04 10:00",
+ multi_model={"ECMWF": 30.2, "GFS": 31.0},
+ )
+
+ _, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
+
+ signal = sd["deb_ensemble_signal"]
+ assert signal["available"] is True
+ assert signal["stance"] == "caution"
+ assert signal["confidence_delta"] < 0
+ assert signal["spread"] == 10.0
+ assert "集合分歧" in ai_context
+
+
# ─── Tests: Dead Market ───
class TestDeadMarket:
diff --git a/web/analysis_service.py b/web/analysis_service.py
index d4b8ca81..20cf31c3 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -1075,6 +1075,7 @@ def _analyze(
probabilities_all = []
mu = None
dynamic_commentary = {"summary": "", "notes": []}
+ deb_ensemble_signal = {}
try:
_, _ai_context, sd = _trend_analyze(raw, sym, city)
@@ -1082,6 +1083,7 @@ def _analyze(
probabilities = sd.get("probabilities", [])
probabilities_all = sd.get("probabilities_all", probabilities)
dynamic_commentary = sd.get("dynamic_commentary") or dynamic_commentary
+ deb_ensemble_signal = sd.get("deb_ensemble_signal") or {}
trend_info["is_dead_market"] = sd.get("trend_info", {}).get("is_dead_market", False)
trend_info["direction"] = sd.get("trend_info", {}).get("direction", trend_info.get("direction", "unknown"))
trend_info["is_cooling"] = sd.get("trend_info", {}).get("is_cooling", False)
@@ -1630,6 +1632,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,
+ "ensemble_signal": deb_ensemble_signal,
**deb_quality,
},
"deviation_monitor": deviation_monitor,