Expose DEB quality guidance

This commit is contained in:
2569718930@qq.com
2026-06-07 23:57:47 +08:00
parent 4389d4a40d
commit bbe3871b7c
9 changed files with 264 additions and 3 deletions
@@ -1213,6 +1213,7 @@ export function LiveTemperatureThresholdChart({
observedHighRunway={observedHighRunway}
wundergroundDailyHigh={wundergroundDailyHigh}
debVal={debVal}
debQuality={chartHourly?.debQuality || null}
modelMin={modelMin}
modelMax={modelMax}
spread={spread}
@@ -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 (
<span
className={clsx("ml-1.5 inline-flex items-center rounded border px-1.5 py-0.5 text-[9px] font-black uppercase leading-none", debQualityClass(quality))}
title={titleParts.join(" · ")}
>
{label}
</span>
);
}
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({
<div className="flex items-center gap-4 text-[11px]">
<span className="font-semibold text-slate-500">
DEB: <strong className="text-orange-600 font-mono">{temp(debVal, tempSymbol)}</strong>
<DebQualityBadge quality={debQuality} isEn={isEn} />
</span>
{modelMin !== null && modelMax !== null && (
<>
@@ -187,6 +234,7 @@ export function TemperatureStatsBars({
<div className="flex flex-col">
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
DEB Max
<DebQualityBadge quality={debQuality} isEn={isEn} />
</span>
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
{temp(debVal, tempSymbol)}
@@ -237,6 +285,7 @@ export function TemperatureStatsBars({
</span>
<strong className="text-blue-600 font-bold">
{temp(debVal, tempSymbol)}
<DebQualityBadge quality={debQuality} isEn={isEn} />
</strong>
</div>
<div className="flex flex-col gap-0.5">
@@ -263,3 +312,4 @@ export function TemperatureStatsBars({
}
export const __buildTemperatureStatsLabelsForTest = buildStatsLabels;
export const __buildDebQualityLabelForTest = debQualityLabel;
@@ -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",
);
}
@@ -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<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | 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: [],
+12
View File
@@ -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<string, unknown> | null;
@@ -328,6 +334,12 @@ export interface DailyModelForecast {
models?: Record<string, number | null>;
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[];
+93 -2
View File
@@ -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,
}
+10
View File
@@ -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"🧬 <b>DEB 融合预测</b><b>{deb_prediction}{temp_symbol}</b> ({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,
+46
View File
@@ -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
+31
View File
@@ -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(),