282 lines
9.0 KiB
Python
282 lines
9.0 KiB
Python
from src.analysis.deb_algorithm import (
|
|
_collapse_forecasts_for_deb,
|
|
calculate_deb_prediction,
|
|
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
|
|
|
|
|
|
def test_calculate_deb_prediction_keeps_raw_and_adds_versioned_bias_correction(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"src.analysis.deb_algorithm.load_history",
|
|
lambda _: {
|
|
"ankara": {
|
|
"2026-04-14": {
|
|
"actual_high": 22.0,
|
|
"deb_prediction": 20.0,
|
|
"forecasts": {"ECMWF": 20.0, "GFS": 20.0},
|
|
},
|
|
"2026-04-15": {
|
|
"actual_high": 23.0,
|
|
"deb_prediction": 21.0,
|
|
"forecasts": {"ECMWF": 21.0, "GFS": 21.0},
|
|
},
|
|
"2026-04-16": {
|
|
"actual_high": 25.0,
|
|
"deb_prediction": 24.0,
|
|
"forecasts": {"ECMWF": 24.0, "GFS": 24.0},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
result = calculate_deb_prediction(
|
|
"ankara",
|
|
{"ECMWF": 24.0, "GFS": 24.0},
|
|
)
|
|
|
|
assert result["raw_prediction"] == 24.0
|
|
assert result["prediction"] == 25.0
|
|
assert result["version"] == "deb_v3_guarded_calibrated"
|
|
assert result["selected_version"] == "deb_v1_recent_bias_corrected"
|
|
assert result["guard_reason"] == "bucket_unavailable"
|
|
assert result["bias_adjustment"] == 1.0
|
|
assert result["bias_samples"] == 3
|
|
|
|
|
|
def test_calculate_deb_prediction_prefers_bucket_calibration_when_enough_samples(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"src.analysis.deb_algorithm.load_history",
|
|
lambda _: {
|
|
"ankara": {
|
|
"2026-04-11": {
|
|
"actual_high": 21.0,
|
|
"deb_prediction": 20.4,
|
|
"forecasts": {"ECMWF": 20.4, "GFS": 20.4},
|
|
},
|
|
"2026-04-12": {
|
|
"actual_high": 22.0,
|
|
"deb_prediction": 21.4,
|
|
"forecasts": {"ECMWF": 21.4, "GFS": 21.4},
|
|
},
|
|
"2026-04-13": {
|
|
"actual_high": 23.0,
|
|
"deb_prediction": 22.4,
|
|
"forecasts": {"ECMWF": 22.4, "GFS": 22.4},
|
|
},
|
|
"2026-04-14": {
|
|
"actual_high": 24.0,
|
|
"deb_prediction": 23.4,
|
|
"forecasts": {"ECMWF": 23.4, "GFS": 23.4},
|
|
},
|
|
"2026-04-15": {
|
|
"actual_high": 25.0,
|
|
"deb_prediction": 24.4,
|
|
"forecasts": {"ECMWF": 24.4, "GFS": 24.4},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
result = calculate_deb_prediction(
|
|
"ankara",
|
|
{"ECMWF": 25.4, "GFS": 25.4},
|
|
)
|
|
|
|
assert result["raw_prediction"] == 25.4
|
|
assert result["prediction"] == 26.0
|
|
assert result["version"] == "deb_v3_guarded_calibrated"
|
|
assert result["selected_version"] == "deb_v2_bucket_calibrated"
|
|
assert result["guard_reason"] == "bucket_same_adjustment"
|
|
assert result["bias_adjustment"] == 0.6
|
|
assert result["bias_samples"] == 5
|
|
|
|
|
|
def test_calculate_deb_prediction_reports_recent_quality_for_effective_deb(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"src.analysis.deb_algorithm.load_history",
|
|
lambda _: {
|
|
"high": {
|
|
"2026-04-11": {"actual_high": 21.0, "deb_prediction": 21.0},
|
|
"2026-04-12": {"actual_high": 22.0, "deb_prediction": 22.0},
|
|
"2026-04-13": {"actual_high": 23.0, "deb_prediction": 23.0},
|
|
"2026-04-14": {"actual_high": 24.0, "deb_prediction": 24.0},
|
|
"2026-04-15": {"actual_high": 25.0, "deb_prediction": 25.0},
|
|
},
|
|
"low": {
|
|
"2026-04-11": {"actual_high": 20.0, "deb_prediction": 16.0},
|
|
"2026-04-12": {"actual_high": 21.0, "deb_prediction": 26.0},
|
|
"2026-04-13": {"actual_high": 22.0, "deb_prediction": 18.0},
|
|
"2026-04-14": {"actual_high": 23.0, "deb_prediction": 28.0},
|
|
"2026-04-15": {"actual_high": 24.0, "deb_prediction": 20.0},
|
|
},
|
|
"thin": {
|
|
"2026-04-14": {"actual_high": 23.0, "deb_prediction": 23.0},
|
|
"2026-04-15": {"actual_high": 24.0, "deb_prediction": 24.0},
|
|
},
|
|
},
|
|
)
|
|
|
|
def raw(_city, _forecasts, **_kwargs):
|
|
return 25.0, "raw"
|
|
|
|
high = calculate_deb_prediction("high", {"ECMWF": 25.0}, raw_calculator=raw)
|
|
low = calculate_deb_prediction("low", {"ECMWF": 25.0}, raw_calculator=raw)
|
|
thin = calculate_deb_prediction("thin", {"ECMWF": 25.0}, raw_calculator=raw)
|
|
|
|
assert high["quality_tier"] == "high"
|
|
assert high["recommendation"] == "primary"
|
|
assert high["recent_hit_rate"] == 100.0
|
|
|
|
assert low["quality_tier"] == "low"
|
|
assert low["recommendation"] == "context_only"
|
|
assert low["recent_hit_rate"] == 0.0
|
|
assert "quality:low" in low["weights_info"]
|
|
|
|
assert thin["quality_tier"] == "insufficient"
|
|
assert thin["recommendation"] == "insufficient"
|
|
assert thin["recent_samples"] == 2
|
|
|
|
|
|
def test_compute_hourly_model_errors_basic():
|
|
from src.analysis.deb_algorithm import compute_hourly_model_errors
|
|
|
|
hourly_forecasts = {
|
|
"ECMWF": [15.0, 15.5, 16.0, 16.5, 17.0, 17.5],
|
|
"GFS": [14.5, 15.0, 15.5, 16.0, 16.5, 17.0],
|
|
}
|
|
hourly_actuals = [15.2, 15.8, 16.3, 16.8, 17.4, 18.0]
|
|
|
|
result = compute_hourly_model_errors(hourly_forecasts, hourly_actuals)
|
|
|
|
assert "ECMWF" in result
|
|
assert "GFS" in result
|
|
assert result["ECMWF"]["samples"] == 6
|
|
assert result["GFS"]["samples"] == 6
|
|
# ECMWF predicted lower, should have smaller MAE
|
|
assert result["ECMWF"]["mae"] < result["GFS"]["mae"]
|
|
assert result["ECMWF"]["rmse"] > 0
|
|
assert result["GFS"]["rmse"] > 0
|
|
|
|
|
|
def test_compute_hourly_model_errors_too_few_samples():
|
|
from src.analysis.deb_algorithm import compute_hourly_model_errors
|
|
|
|
hourly_forecasts = {"ECMWF": [15.0, 15.5]}
|
|
hourly_actuals = [15.0, 15.5]
|
|
|
|
result = compute_hourly_model_errors(hourly_forecasts, hourly_actuals)
|
|
assert result == {} # < 6 samples, returns empty
|
|
|
|
|
|
def test_blend_mae_with_hourly():
|
|
from src.analysis.deb_algorithm import _blend_mae
|
|
|
|
# Full 24 hourly samples → 0.7 hourly weight
|
|
h_err = {"mae": 0.8, "rmse": 1.0, "samples": 24}
|
|
blended = _blend_mae(1.5, h_err)
|
|
# 1.5 * 0.3 + 0.8 * 0.7 = 0.45 + 0.56 = 1.01
|
|
assert abs(blended - 1.01) < 0.01
|
|
|
|
# 12 hourly samples → ~0.35 hourly weight
|
|
h_err2 = {"mae": 1.0, "rmse": 1.2, "samples": 12}
|
|
blended2 = _blend_mae(2.0, h_err2)
|
|
# 12/24 * 0.7 = 0.35 hourly weight
|
|
expected = 2.0 * (1 - 0.35) + 1.0 * 0.35 # 1.30 + 0.35 = 1.65
|
|
assert abs(blended2 - expected) < 0.01
|
|
|
|
# No hourly error → returns daily MAE unchanged
|
|
blended3 = _blend_mae(2.5, None)
|
|
assert blended3 == 2.5
|
|
|
|
# Too few samples (< 6) → returns daily MAE unchanged
|
|
h_err4 = {"mae": 0.5, "samples": 3}
|
|
blended4 = _blend_mae(3.0, h_err4)
|
|
assert blended4 == 3.0
|