Add DEB ensemble confidence signal

This commit is contained in:
2569718930@qq.com
2026-06-25 02:49:12 +08:00
parent 578f547fa7
commit ac565c4a00
7 changed files with 308 additions and 7 deletions
@@ -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 (
<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(" · ")}
title={debQualityTitle(quality, isEn)}
>
{label}
{label || "DEB"}
{ensembleLabel && (
<span className="ml-1 border-l border-current/30 pl-1">
{ensembleLabel}
</span>
)}
</span>
);
}
@@ -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;
@@ -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",
);
}
@@ -1605,7 +1605,16 @@ function seedRunwayPlateHistoryFromRow(
type ChartRenderState = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | 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,
+16
View File
@@ -221,6 +221,21 @@ export interface DebHourlyPath {
correction?: Record<string, unknown> | 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<string, unknown> | null;
ensemble_signal?: DebEnsembleSignal | null;
}
export interface CitySummary {
+126
View File
@@ -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,
+66
View File
@@ -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:
+3
View File
@@ -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,