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(),