Group model stack and dedupe DEB families
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user