Group model stack and dedupe DEB families

This commit is contained in:
2569718930@qq.com
2026-04-17 00:35:48 +08:00
parent 7909a16002
commit 9ec86e0504
5 changed files with 461 additions and 38 deletions
@@ -1005,7 +1005,7 @@
.root :global(.model-bars) {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.root :global(.model-row) {
@@ -1026,6 +1026,91 @@
text-overflow: ellipsis;
}
.root :global(.model-row-rich) {
align-items: flex-start;
}
.root :global(.model-row-rich .model-name) {
width: 118px;
white-space: normal;
line-height: 1.25;
}
.root :global(.model-row-rich .model-name strong) {
display: block;
color: var(--text-primary);
font-size: 12px;
}
.root :global(.model-row-rich .model-name span) {
display: block;
margin-top: 2px;
color: var(--text-muted);
font-size: 10px;
font-weight: 500;
}
.root :global(.model-stack-summary) {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 2px;
}
.root :global(.model-stack-summary span) {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 6px;
background: rgba(15, 23, 42, 0.5);
color: var(--text-secondary);
font-size: 11px;
padding: 5px 8px;
}
.root :global(.model-stack-summary strong) {
color: var(--text-primary);
font-weight: 800;
}
.root :global(.model-group) {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-left: 2px solid rgba(148, 163, 184, 0.26);
background: rgba(15, 23, 42, 0.28);
border-radius: 6px;
}
.root :global(.model-group-cyan) {
border-left-color: rgba(34, 211, 238, 0.75);
}
.root :global(.model-group-blue) {
border-left-color: rgba(96, 165, 250, 0.75);
}
.root :global(.model-group-amber) {
border-left-color: rgba(245, 158, 11, 0.8);
}
.root :global(.model-group-heading) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.root :global(.model-group-heading em) {
color: var(--text-muted);
font-style: normal;
letter-spacing: 0;
}
.root :global(.model-bar-track) {
flex: 1;
height: 20px;
@@ -1038,7 +1123,7 @@
.root :global(.model-bar-fill) {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, var(--accent-purple), var(--accent-blue));
background: linear-gradient(90deg, rgba(14, 165, 233, 0.72), rgba(34, 211, 238, 0.92));
transition: width 0.6s ease-out;
display: flex;
align-items: center;
+169 -29
View File
@@ -100,6 +100,80 @@ function getMarketYesPrice(scan?: MarketScan | null) {
return null;
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function getModelGroupMeta(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const tier = String(meta.tier || "").toLowerCase();
const upperName = String(name || "").toUpperCase();
if (tier.includes("ai") || upperName.includes("AIFS")) {
return {
key: "ai",
label: locale === "en-US" ? "AI forecast" : "AI 预报",
order: 1,
tone: "blue",
};
}
if (
tier.includes("europe") ||
upperName.includes("ICON-EU") ||
upperName.includes("ICON-D2")
) {
return {
key: "europe",
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
order: 2,
tone: "cyan",
};
}
if (
tier.includes("north_america") ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key: "north-america",
label: locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
}
return {
key: "global",
label: locale === "en-US" ? "Global baseline" : "全球基准",
order: 0,
tone: "neutral",
};
}
function formatModelMetaLine(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const provider = String(meta.provider || "").trim();
const model = String(meta.model || "").trim();
const horizon = String(meta.horizon || "").trim();
const resolution = Number(meta.resolution_km);
const parts = [
provider,
model && model !== name ? model : "",
Number.isFinite(resolution)
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
: "",
horizon,
].filter(Boolean);
return parts.join(" · ");
}
function getMarketNoPrice(scan?: MarketScan | null) {
if (scan?.no_buy != null) {
const direct = Number(scan.no_buy);
@@ -619,6 +693,8 @@ export function ModelForecast({
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelMetadata =
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
@@ -648,11 +724,66 @@ export function ModelForecast({
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
);
const groupedEntries = sortedEntries.reduce(
(acc, [name, value]) => {
const group = getModelGroupMeta(name, modelMetadata, locale);
const existing = acc.find((item) => item.key === group.key);
const entry = {
metaLine: formatModelMetaLine(name, modelMetadata, locale),
name,
value: Number(value),
};
if (existing) {
existing.entries.push(entry);
} else {
acc.push({ ...group, entries: [entry] });
}
return acc;
},
[] as Array<{
entries: Array<{ metaLine: string; name: string; value: number }>;
key: string;
label: string;
order: number;
tone: string;
}>,
).sort((a, b) => a.order - b.order);
const spread =
numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues)
: null;
const metadataSource =
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
? "Open-Meteo"
: null;
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<div className="model-stack-summary">
<span>
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
<strong>{modelEntries.length}</strong>
</span>
<span>
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
<strong>
{spread != null
? `${spread.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</span>
{metadataSource && (
<span>
{locale === "en-US" ? "Source" : "来源"} ·{" "}
<strong>{metadataSource}</strong>
</span>
)}
</div>
{hasSingleModelOnly && (
<div
style={{
@@ -666,39 +797,48 @@ export function ModelForecast({
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{modelEntries
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
.map(([name, value]) => {
const numeric = Number(value);
const width = ((numeric - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
{groupedEntries.map((group) => (
<div
key={group.key}
className={clsx("model-group", `model-group-${group.tone}`)}
>
<div className="model-group-heading">
<span>{group.label}</span>
<em>{group.entries.length}</em>
</div>
{group.entries.map(({ metaLine, name, value }) => {
const width = ((value - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row">
<div className="model-name" title={name}>
{name}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{numeric}
{detail.temp_symbol}
return (
<div key={name} className="model-row model-row-rich">
<div className="model-name" title={metaLine || name}>
<strong>{name}</strong>
{metaLine && <span>{metaLine}</span>}
</div>
{debLine != null && (
<div className="model-bar-track">
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
className="model-bar-fill"
style={{ width: `${width}%` }}
>
{value}
{detail.temp_symbol}
</div>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
))}
{view.deb != null && (
<div
className="model-row"
+19
View File
@@ -238,6 +238,25 @@ export interface SourceForecasts {
weather_gov?: {
forecast_periods?: WeatherGovPeriod[];
};
open_meteo_multi_model?: {
source?: string | null;
provider?: string | null;
dates?: string[];
model_metadata?: Record<
string,
{
label?: string | null;
provider?: string | null;
model?: string | null;
tier?: string | null;
resolution_km?: number | null;
horizon?: string | null;
open_meteo_model?: string | null;
}
>;
model_keys?: Record<string, string>;
attribution?: string | null;
};
}
export interface DailyModelForecast {
+95 -7
View File
@@ -65,6 +65,82 @@ def _is_excluded_model_name(model_name: str) -> bool:
return "meteoblue" in normalized
def _normalize_deb_model_name(model_name: str) -> str:
return (
str(model_name or "")
.strip()
.lower()
.replace(" ", "")
.replace("_", "")
.replace("-", "")
.replace("/", "")
)
def _deb_model_family(model_name: str) -> str:
normalized = _normalize_deb_model_name(model_name)
if normalized in {"icon", "iconeu", "icond2"}:
return "dwd_icon"
if normalized in {"gem", "gdps", "rdps", "hrdps"}:
return "eccc_gem"
if normalized in {"ecmwfaifs", "aifs"}:
return "ecmwf_aifs"
if normalized in {"ecmwf"}:
return "ecmwf_ifs"
return normalized or str(model_name or "").strip()
def _deb_model_priority(model_name: str) -> int:
normalized = _normalize_deb_model_name(model_name)
return {
"icond2": 40,
"iconeu": 30,
"icon": 20,
"hrdps": 40,
"rdps": 35,
"gdps": 30,
"gem": 20,
"ecmwfaifs": 30,
"ecmwf": 30,
"gfs": 30,
"jma": 30,
"mgm": 45,
"nws": 45,
"hko": 45,
"lgbm": 50,
"openmeteo": 15,
}.get(normalized, 10)
def _collapse_forecasts_for_deb(current_forecasts):
"""
Avoid counting the same modelling family multiple times in DEB.
Regional/high-resolution variants replace their global family member when present.
"""
collapsed = {}
representatives = {}
for model_name, value in (current_forecasts or {}).items():
if value is None or _is_excluded_model_name(model_name):
continue
try:
numeric = float(value)
except (TypeError, ValueError):
continue
family = _deb_model_family(model_name)
current_rep = representatives.get(family)
priority = _deb_model_priority(model_name)
if current_rep is None or priority > current_rep["priority"]:
representatives[family] = {
"name": model_name,
"priority": priority,
"value": numeric,
}
for rep in representatives.values():
collapsed[rep["name"]] = rep["value"]
return collapsed
def load_history(filepath):
global _history_cache, _history_mtime
mode = get_state_storage_mode()
@@ -914,11 +990,15 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
history_file = os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
current_forecasts = {
k: v
for k, v in (current_forecasts or {}).items()
if not _is_excluded_model_name(k)
}
raw_forecast_count = len(
[
v
for k, v in (current_forecasts or {}).items()
if v is not None and not _is_excluded_model_name(k)
]
)
current_forecasts = _collapse_forecasts_for_deb(current_forecasts)
dedup_note = "家族去重" if raw_forecast_count > len(current_forecasts) else ""
if city_name not in data or not data[city_name]:
# 没有历史数据,返回简单的平均/中位数
@@ -926,7 +1006,10 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
if not valid_vals:
return None, "暂无模型数据"
avg = sum(valid_vals) / len(valid_vals)
return round(avg, 1), "等权平均(历史数据不足)"
note = "等权平均(历史数据不足)"
if dedup_note:
note = f"{note} | {dedup_note}"
return round(avg, 1), note
# 获取过去 lookback_days 天的有 actual_high 的记录
city_data = data[city_name]
@@ -968,7 +1051,10 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
if not valid_vals:
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
avg = sum(valid_vals) / len(valid_vals)
return round(avg, 1), f"等权平均(由于仅{days_used}天历史)"
note = f"等权平均(由于仅{days_used}天历史)"
if dedup_note:
note = f"{note} | {dedup_note}"
return round(avg, 1), note
# 计算 MAE
maes = {}
@@ -1002,6 +1088,8 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
weight_str_parts = []
for m, w in sorted_models[:3]:
weight_str_parts.append(f"{m}({w * 100:.0f}%,MAE:{maes[m]:.1f}°)")
if dedup_note:
weight_str_parts.append(dedup_note)
return round(blended_high, 1), " | ".join(weight_str_parts)
+91
View File
@@ -0,0 +1,91 @@
from src.analysis.deb_algorithm import (
_collapse_forecasts_for_deb,
calculate_dynamic_weights,
)
def test_deb_collapses_regional_model_families_before_blending():
collapsed = _collapse_forecasts_for_deb(
{
"ECMWF": 20.0,
"ECMWF AIFS": 20.5,
"GFS": 19.5,
"ICON": 21.0,
"ICON-EU": 21.4,
"ICON-D2": 21.8,
"GEM": 18.8,
"GDPS": 19.0,
"RDPS": 19.4,
"HRDPS": 20.2,
"JMA": 19.8,
}
)
assert collapsed == {
"ECMWF": 20.0,
"ECMWF AIFS": 20.5,
"GFS": 19.5,
"ICON-D2": 21.8,
"HRDPS": 20.2,
"JMA": 19.8,
}
def test_deb_equal_weight_uses_deduped_family_values(monkeypatch):
monkeypatch.setattr("src.analysis.deb_algorithm.load_history", lambda _: {})
blended, info = calculate_dynamic_weights(
"ankara",
{
"ECMWF": 20.0,
"GFS": 20.0,
"ICON": 30.0,
"ICON-EU": 40.0,
"ICON-D2": 50.0,
},
)
assert blended == 30.0
assert "家族去重" in info
def test_deb_weighted_path_uses_deduped_family_values(monkeypatch):
monkeypatch.setattr(
"src.analysis.deb_algorithm.load_history",
lambda _: {
"ankara": {
"2026-04-14": {
"actual_high": 22.0,
"forecasts": {
"ECMWF": 22.0,
"GFS": 21.0,
"ICON-D2": 30.0,
},
},
"2026-04-15": {
"actual_high": 23.0,
"forecasts": {
"ECMWF": 23.0,
"GFS": 22.0,
"ICON-D2": 31.0,
},
},
}
},
)
blended, info = calculate_dynamic_weights(
"ankara",
{
"ECMWF": 24.0,
"GFS": 24.0,
"ICON": 32.0,
"ICON-EU": 34.0,
"ICON-D2": 36.0,
},
lookback_days=5,
)
assert blended < 30.0
assert "ICON-D2" in info
assert "家族去重" in info