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,