Add DEB ensemble confidence signal
This commit is contained in:
@@ -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;
|
||||
|
||||
+38
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user