Group model stack and dedupe DEB families
This commit is contained in:
@@ -1005,7 +1005,7 @@
|
|||||||
.root :global(.model-bars) {
|
.root :global(.model-bars) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.model-row) {
|
.root :global(.model-row) {
|
||||||
@@ -1026,6 +1026,91 @@
|
|||||||
text-overflow: ellipsis;
|
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) {
|
.root :global(.model-bar-track) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -1038,7 +1123,7 @@
|
|||||||
.root :global(.model-bar-fill) {
|
.root :global(.model-bar-fill) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 6px;
|
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;
|
transition: width 0.6s ease-out;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -100,6 +100,80 @@ function getMarketYesPrice(scan?: MarketScan | null) {
|
|||||||
return 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) {
|
function getMarketNoPrice(scan?: MarketScan | null) {
|
||||||
if (scan?.no_buy != null) {
|
if (scan?.no_buy != null) {
|
||||||
const direct = Number(scan.no_buy);
|
const direct = Number(scan.no_buy);
|
||||||
@@ -619,6 +693,8 @@ export function ModelForecast({
|
|||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const view = getModelView(detail, targetDate);
|
const view = getModelView(detail, targetDate);
|
||||||
const modelsMap = { ...view.models };
|
const modelsMap = { ...view.models };
|
||||||
|
const modelMetadata =
|
||||||
|
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
|
||||||
|
|
||||||
const modelEntries = Object.entries(modelsMap).filter(
|
const modelEntries = Object.entries(modelsMap).filter(
|
||||||
([, value]) =>
|
([, value]) =>
|
||||||
@@ -648,11 +724,66 @@ export function ModelForecast({
|
|||||||
? Math.max(...comparisonValues) + 1
|
? Math.max(...comparisonValues) + 1
|
||||||
: 1;
|
: 1;
|
||||||
const range = Math.max(maxValue - minValue, 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 (
|
return (
|
||||||
<section className="models-section">
|
<section className="models-section">
|
||||||
{!hideTitle && <h3>{t("section.models")}</h3>}
|
{!hideTitle && <h3>{t("section.models")}</h3>}
|
||||||
<div className="model-bars">
|
<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 && (
|
{hasSingleModelOnly && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -666,39 +797,48 @@ export function ModelForecast({
|
|||||||
: "当前处于单模型回退,其他模型结果还没回传。"}
|
: "当前处于单模型回退,其他模型结果还没回传。"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modelEntries
|
{groupedEntries.map((group) => (
|
||||||
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
|
<div
|
||||||
.map(([name, value]) => {
|
key={group.key}
|
||||||
const numeric = Number(value);
|
className={clsx("model-group", `model-group-${group.tone}`)}
|
||||||
const width = ((numeric - minValue) / range) * 100;
|
>
|
||||||
const debLine =
|
<div className="model-group-heading">
|
||||||
view.deb != null
|
<span>{group.label}</span>
|
||||||
? ((Number(view.deb) - minValue) / range) * 100
|
<em>{group.entries.length}</em>
|
||||||
: null;
|
</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 (
|
return (
|
||||||
<div key={name} className="model-row">
|
<div key={name} className="model-row model-row-rich">
|
||||||
<div className="model-name" title={name}>
|
<div className="model-name" title={metaLine || name}>
|
||||||
{name}
|
<strong>{name}</strong>
|
||||||
</div>
|
{metaLine && <span>{metaLine}</span>}
|
||||||
<div className="model-bar-track">
|
|
||||||
<div
|
|
||||||
className="model-bar-fill"
|
|
||||||
style={{ width: `${width}%` }}
|
|
||||||
>
|
|
||||||
{numeric}
|
|
||||||
{detail.temp_symbol}
|
|
||||||
</div>
|
</div>
|
||||||
{debLine != null && (
|
<div className="model-bar-track">
|
||||||
<div
|
<div
|
||||||
className="model-deb-line"
|
className="model-bar-fill"
|
||||||
style={{ left: `${debLine}%` }}
|
style={{ width: `${width}%` }}
|
||||||
/>
|
>
|
||||||
)}
|
{value}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</div>
|
||||||
|
{debLine != null && (
|
||||||
|
<div
|
||||||
|
className="model-deb-line"
|
||||||
|
style={{ left: `${debLine}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
|
))}
|
||||||
{view.deb != null && (
|
{view.deb != null && (
|
||||||
<div
|
<div
|
||||||
className="model-row"
|
className="model-row"
|
||||||
|
|||||||
@@ -238,6 +238,25 @@ export interface SourceForecasts {
|
|||||||
weather_gov?: {
|
weather_gov?: {
|
||||||
forecast_periods?: WeatherGovPeriod[];
|
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 {
|
export interface DailyModelForecast {
|
||||||
|
|||||||
@@ -65,6 +65,82 @@ def _is_excluded_model_name(model_name: str) -> bool:
|
|||||||
return "meteoblue" in normalized
|
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):
|
def load_history(filepath):
|
||||||
global _history_cache, _history_mtime
|
global _history_cache, _history_mtime
|
||||||
mode = get_state_storage_mode()
|
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")
|
history_file = os.path.join(project_root, "data", "daily_records.json")
|
||||||
data = load_history(history_file)
|
data = load_history(history_file)
|
||||||
|
|
||||||
current_forecasts = {
|
raw_forecast_count = len(
|
||||||
k: v
|
[
|
||||||
for k, v in (current_forecasts or {}).items()
|
v
|
||||||
if not _is_excluded_model_name(k)
|
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]:
|
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:
|
if not valid_vals:
|
||||||
return None, "暂无模型数据"
|
return None, "暂无模型数据"
|
||||||
avg = sum(valid_vals) / len(valid_vals)
|
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 的记录
|
# 获取过去 lookback_days 天的有 actual_high 的记录
|
||||||
city_data = data[city_name]
|
city_data = data[city_name]
|
||||||
@@ -968,7 +1051,10 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
|
|||||||
if not valid_vals:
|
if not valid_vals:
|
||||||
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
|
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
|
||||||
avg = sum(valid_vals) / len(valid_vals)
|
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
|
# 计算 MAE
|
||||||
maes = {}
|
maes = {}
|
||||||
@@ -1002,6 +1088,8 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
|
|||||||
weight_str_parts = []
|
weight_str_parts = []
|
||||||
for m, w in sorted_models[:3]:
|
for m, w in sorted_models[:3]:
|
||||||
weight_str_parts.append(f"{m}({w * 100:.0f}%,MAE:{maes[m]:.1f}°)")
|
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)
|
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