diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 0062070e..87a8cc9c 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 2c70bad3..4dc856c5 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -100,6 +100,80 @@ function getMarketYesPrice(scan?: MarketScan | null) { return null; } +type ModelMetadata = NonNullable< + NonNullable["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 (
{!hideTitle &&

{t("section.models")}

}
+
+ + {locale === "en-US" ? "Available models" : "可用模型"} ·{" "} + {modelEntries.length} + + + {locale === "en-US" ? "Spread" : "分歧"} ·{" "} + + {spread != null + ? `${spread.toFixed(1)}${detail.temp_symbol}` + : "--"} + + + {metadataSource && ( + + {locale === "en-US" ? "Source" : "来源"} ·{" "} + {metadataSource} + + )} +
{hasSingleModelOnly && (
)} - {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) => ( +
+
+ {group.label} + {group.entries.length} +
+ {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 ( -
-
- {name} -
-
-
- {numeric} - {detail.temp_symbol} + return ( +
+
+ {name} + {metaLine && {metaLine}}
- {debLine != null && ( +
- )} + className="model-bar-fill" + style={{ width: `${width}%` }} + > + {value} + {detail.temp_symbol} +
+ {debLine != null && ( +
+ )} +
-
- ); - })} + ); + })} +
+ ))} {view.deb != null && (
; + model_keys?: Record; + attribution?: string | null; + }; } export interface DailyModelForecast { diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py index cc801bf7..ec45598a 100644 --- a/src/analysis/deb_algorithm.py +++ b/src/analysis/deb_algorithm.py @@ -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) diff --git a/tests/test_deb_model_family.py b/tests/test_deb_model_family.py new file mode 100644 index 00000000..082f7810 --- /dev/null +++ b/tests/test_deb_model_family.py @@ -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