Fix stale multi-model DEB inputs

This commit is contained in:
2569718930@qq.com
2026-06-25 16:35:42 +08:00
parent ac565c4a00
commit a76ec62882
8 changed files with 319 additions and 62 deletions
+13 -10
View File
@@ -20,6 +20,7 @@ from src.analysis.deb_hourly_consensus import build_deb_hourly_consensus_path
from src.analysis.settlement_rounding import apply_city_settlement, is_exact_settlement_city
from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
SETTLEMENT_SOURCE_LABELS = {
"metar": "METAR",
@@ -464,11 +465,6 @@ def analyze_weather_trend(
if weather_data.get("cwa_forecast") is not None:
current_forecasts["CWA(台气象)"] = _sf(weather_data.get("cwa_forecast"))
mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
for m_name, m_val in mm_forecasts.items():
if m_val is not None and not _is_excluded_model_name(m_name):
current_forecasts[m_name] = _sf(m_val)
forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_high = max(forecast_highs) if forecast_highs else None
forecast_median = (
@@ -545,11 +541,18 @@ def analyze_weather_trend(
current_forecasts["Open-Meteo"] = local_day_high
except Exception:
pass
forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_high = max(forecast_highs) if forecast_highs else None
forecast_median = (
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
)
mm_forecasts = multi_model_forecasts_for_local_date(
weather_data.get("multi_model", {}),
local_date_str,
)
for m_name, m_val in mm_forecasts.items():
if m_val is not None and not _is_excluded_model_name(m_name):
current_forecasts[m_name] = _sf(m_val)
forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_high = max(forecast_highs) if forecast_highs else None
forecast_median = (
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
)
# === DEB ===
deb_prediction = None
@@ -2,6 +2,8 @@ from __future__ import annotations
from typing import Any, Dict
from src.data_collection.multi_model_freshness import multi_model_has_current_window
def _open_meteo_cache_key(
lat: float,
@@ -58,8 +60,9 @@ def _read_open_meteo_bundle_from_cache(
)
with collector._multi_model_cache_lock:
mm_cached = collector._multi_model_cache.get(mm_key)
if mm_cached and isinstance(mm_cached.get("data"), dict):
results["multi_model"] = dict(mm_cached["data"])
mm_data = mm_cached.get("data") if isinstance(mm_cached, dict) else None
if isinstance(mm_data, dict) and multi_model_has_current_window(mm_data):
results["multi_model"] = dict(mm_data)
return results
@@ -0,0 +1,131 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any, Dict, Optional
def _parse_date(value: Any) -> Optional[date]:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text[:10]).date()
except Exception:
return None
def _numeric(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except Exception:
return None
def _date_strings_from_hourly(multi_model: Dict[str, Any]) -> set[str]:
dates = set()
for raw_time in multi_model.get("hourly_times") or []:
text = str(raw_time or "")
if len(text) >= 10:
dates.add(text[:10])
return dates
def _has_numeric_hourly_for_date(multi_model: Dict[str, Any], local_date: str) -> bool:
times = multi_model.get("hourly_times") or []
forecasts = multi_model.get("hourly_forecasts") or {}
if not isinstance(forecasts, dict):
return False
for idx, raw_time in enumerate(times):
if not str(raw_time or "").startswith(local_date):
continue
for values in forecasts.values():
if isinstance(values, list) and idx < len(values) and _numeric(values[idx]) is not None:
return True
return False
def multi_model_has_current_window(multi_model: Any, *, today: Optional[date] = None) -> bool:
"""Return False when cached multi-model data is definitely older than today."""
if not isinstance(multi_model, dict):
return False
today = today or datetime.now(timezone.utc).date()
raw_date_values = []
raw_date_values.extend(multi_model.get("dates") or [])
daily = multi_model.get("daily_forecasts")
if isinstance(daily, dict):
raw_date_values.extend(daily.keys())
raw_date_values.extend(_date_strings_from_hourly(multi_model))
parsed_dates = [parsed for raw in raw_date_values for parsed in [_parse_date(raw)] if parsed]
if not parsed_dates:
return True
return max(parsed_dates) >= today
def multi_model_covers_local_date(multi_model: Any, local_date: str) -> bool:
if not isinstance(multi_model, dict):
return False
wanted = str(local_date or "").strip()
if not wanted:
return bool(multi_model.get("forecasts"))
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
day_models = daily.get(wanted) if isinstance(daily.get(wanted), dict) else {}
if any(_numeric(value) is not None for value in day_models.values()):
return True
if _has_numeric_hourly_for_date(multi_model, wanted):
return True
dates = [str(value) for value in (multi_model.get("dates") or [])]
if dates:
return wanted in dates
has_dated_payload = bool(daily) or bool(multi_model.get("hourly_times"))
return bool(multi_model.get("forecasts")) and not has_dated_payload
def multi_model_forecasts_for_local_date(multi_model: Any, local_date: str) -> Dict[str, float]:
if not isinstance(multi_model, dict) or not multi_model_covers_local_date(multi_model, local_date):
return {}
wanted = str(local_date or "").strip()
models: Dict[str, float] = {}
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
day_models = daily.get(wanted) if isinstance(daily.get(wanted), dict) else {}
for model, value in day_models.items():
parsed = _numeric(value)
if parsed is not None:
models[str(model)] = parsed
times = multi_model.get("hourly_times") or []
hourly = multi_model.get("hourly_forecasts") if isinstance(multi_model.get("hourly_forecasts"), dict) else {}
day_indexes = [
idx
for idx, raw_time in enumerate(times)
if wanted and str(raw_time or "").startswith(wanted)
]
for model, values in hourly.items():
if not isinstance(values, list):
continue
day_values = [
parsed
for idx in day_indexes
if idx < len(values)
for parsed in [_numeric(values[idx])]
if parsed is not None
]
if day_values:
current = models.get(str(model))
models[str(model)] = max(day_values) if current is None else max(current, max(day_values))
if models:
return models
forecasts = multi_model.get("forecasts") if isinstance(multi_model.get("forecasts"), dict) else {}
for model, value in forecasts.items():
parsed = _numeric(value)
if parsed is not None:
models[str(model)] = parsed
return models
+15 -6
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
from loguru import logger
from src.data_collection.multi_model_freshness import multi_model_has_current_window
from src.utils.metrics import record_source_call
@@ -868,15 +869,17 @@ class NwsOpenMeteoSourceMixin:
logger.debug(f"Open-Meteo Multi-model 冷却期中,跳过请求,还需 {remaining}s")
with self._multi_model_cache_lock:
stale = self._multi_model_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
stale_data = stale.get("data") if isinstance(stale, dict) else None
if isinstance(stale_data, dict) and multi_model_has_current_window(stale_data):
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
return dict(stale["data"])
return dict(stale_data)
self._load_open_meteo_disk_cache()
with self._multi_model_cache_lock:
stale2 = self._multi_model_cache.get(cache_key)
if stale2 and isinstance(stale2.get("data"), dict):
stale2_data = stale2.get("data") if isinstance(stale2, dict) else None
if isinstance(stale2_data, dict) and multi_model_has_current_window(stale2_data):
record_source_call("open_meteo", "multi_model", "disk_fallback", (time.perf_counter() - started) * 1000.0)
return dict(stale2["data"])
return dict(stale2_data)
record_source_call("open_meteo", "multi_model", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
return None
@@ -888,6 +891,11 @@ class NwsOpenMeteoSourceMixin:
< self.open_meteo_multi_model_cache_ttl_sec
):
cached_data = cached.get("data")
if isinstance(cached_data, dict):
if not multi_model_has_current_window(cached_data):
self._multi_model_cache.pop(cache_key, None)
record_source_call("open_meteo", "multi_model", "expired_cache_skip", (time.perf_counter() - started) * 1000.0)
cached_data = None
if isinstance(cached_data, dict):
record_source_call("open_meteo", "multi_model", "cache_hit", (time.perf_counter() - started) * 1000.0)
return dict(cached_data)
@@ -996,8 +1004,9 @@ class NwsOpenMeteoSourceMixin:
logger.warning(f"Multi-model API 请求失败: {e}")
with self._multi_model_cache_lock:
stale = self._multi_model_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"])
stale_data = stale.get("data") if isinstance(stale, dict) else None
if isinstance(stale_data, dict) and multi_model_has_current_window(stale_data):
fallback = dict(stale_data)
fallback["stale_cache"] = True
record_source_call("open_meteo", "multi_model", "stale_cache", (time.perf_counter() - started) * 1000.0)
return fallback
+64 -1
View File
@@ -1,3 +1,6 @@
from datetime import datetime, timezone, timedelta
import time
from src.data_collection.nws_open_meteo_sources import (
OPEN_METEO_MULTI_MODEL_ORDER,
_parse_open_meteo_multi_model_daily,
@@ -268,6 +271,8 @@ def test_fetch_all_sources_delegates_non_hf_forecast_bundle(monkeypatch, tmp_pat
def test_open_meteo_cache_only_reads_multi_model_without_forecast_cache():
from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast_bundle
today = datetime.now(timezone.utc).date().isoformat()
class DummyLock:
def __enter__(self):
return None
@@ -283,7 +288,7 @@ def test_open_meteo_cache_only_reads_multi_model_without_forecast_cache():
_multi_model_cache = {
"48.9694:2.4414:paris:c:v5": {
"data": {
"hourly_times": ["2026-06-16T15:00"],
"hourly_times": [f"{today}T15:00"],
"hourly_forecasts": {"ECMWF": [24.5]},
"forecasts": {"ECMWF": 27.0},
}
@@ -450,6 +455,64 @@ def test_persisted_open_meteo_cooldown_skips_outbound_request(monkeypatch, tmp_p
assert result is not None # cooldown returns cached data
def test_fetch_multi_model_ignores_cache_when_dates_are_stale(monkeypatch, tmp_path):
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
collector = WeatherDataCollector({})
today = datetime.now(timezone.utc).date()
old_dates = [
(today - timedelta(days=11)).isoformat(),
(today - timedelta(days=10)).isoformat(),
]
fresh_dates = [today.isoformat(), (today + timedelta(days=1)).isoformat()]
cache_key = (
f"{round(float(48.9694), 4)}:{round(float(2.4414), 4)}:paris:"
f"c:{collector.multi_model_cache_version}"
)
collector._multi_model_cache[cache_key] = {
"t": time.time(),
"data": {
"dates": old_dates,
"daily_forecasts": {old_dates[0]: {"ECMWF": 24.0}},
"forecasts": {"ECMWF": 24.0},
"hourly_times": [f"{old_dates[0]}T15:00"],
"hourly_forecasts": {"ECMWF": [23.0]},
},
}
calls = []
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {
"daily": {
"time": fresh_dates,
"temperature_2m_max_ecmwf_ifs025": [39.2, 33.0],
"temperature_2m_max_gfs_seamless": [38.6, 32.5],
},
"hourly": {
"time": [f"{fresh_dates[0]}T15:00", f"{fresh_dates[0]}T16:00"],
"temperature_2m_ecmwf_ifs025": [38.8, 39.2],
"temperature_2m_gfs_seamless": [38.0, 38.6],
},
}
monkeypatch.setattr(collector, "_wait_open_meteo_slot", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
collector,
"_http_get",
lambda *args, **kwargs: calls.append((args, kwargs)) or FakeResponse(),
)
result = collector.fetch_multi_model(48.9694, 2.4414, city="paris")
assert len(calls) == 1
assert result["dates"][:2] == fresh_dates
assert result["forecasts"]["ECMWF"] == 39.2
assert result["forecasts"]["GFS"] == 38.6
def test_multi_model_hourly_parser():
from src.data_collection.nws_open_meteo_sources import _parse_open_meteo_multi_model_hourly
+51 -16
View File
@@ -1401,6 +1401,8 @@ def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch):
def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
import asyncio
local_date = datetime.now(timezone.utc).date().isoformat()
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
@@ -1408,7 +1410,7 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
"payload": {
"name": city,
"display_name": city.title(),
"local_date": "2026-06-16",
"local_date": local_date,
"local_time": "15:20",
"temp_symbol": "°C",
"current": {"temp": 20.0},
@@ -1433,7 +1435,7 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
monkeypatch.setattr(collector, "_multi_model_cache", {
"48.9694:2.4414:paris:c:v5": {
"data": {
"hourly_times": ["2026-06-16T15:00"],
"hourly_times": [f"{local_date}T15:00"],
"hourly_forecasts": {"ECMWF": [24.5]},
"forecasts": {"ECMWF": 27.0},
}
@@ -1458,6 +1460,9 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
import asyncio
local_date = datetime.now(timezone.utc).date().isoformat()
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=3)).isoformat()
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
@@ -1465,13 +1470,13 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
"payload": {
"name": city,
"display_name": city.title(),
"local_date": "2026-06-17",
"local_date": local_date,
"local_time": "15:20",
"temp_symbol": "°C",
"current": {"temp": 20.0},
"hourly": {"times": ["15:00"], "temps": [20.0]},
"multi_model": {
"hourly_times": ["2026-06-14T15:00", "2026-06-16T23:00"],
"hourly_times": [f"{stale_date}T15:00", f"{stale_date}T23:00"],
"hourly_forecasts": {"ECMWF": [21.0, 22.0]},
},
},
@@ -1493,7 +1498,7 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
monkeypatch.setattr(collector, "_multi_model_cache", {
"48.9694:2.4414:paris:c:v5": {
"data": {
"hourly_times": ["2026-06-17T15:00", "2026-06-17T16:00"],
"hourly_times": [f"{local_date}T15:00", f"{local_date}T16:00"],
"hourly_forecasts": {"ECMWF": [24.5, 25.0]},
"forecasts": {"ECMWF": 27.0},
}
@@ -1512,10 +1517,29 @@ def test_chart_data_cache_hit_replaces_stale_multi_model_hourly(monkeypatch):
payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False))
assert payload["multi_model"]["hourly_times"] == ["2026-06-17T15:00", "2026-06-17T16:00"]
assert payload["multi_model"]["hourly_times"] == [f"{local_date}T15:00", f"{local_date}T16:00"]
assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5, 25.0]
def test_multi_model_daily_models_for_date_rejects_stale_dated_forecasts():
local_date = datetime.now(timezone.utc).date().isoformat()
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat()
models = city_api._multi_model_daily_models_for_date(
{
"daily_forecasts": {
stale_date: {"ECMWF": 24.0},
},
"hourly_times": [f"{stale_date}T15:00"],
"hourly_forecasts": {"ECMWF": [24.0]},
"forecasts": {"ECMWF": 24.0},
},
local_date,
)
assert models == {}
def test_chart_data_cache_hit_refreshes_when_multi_model_cache_is_stale(monkeypatch):
import asyncio
@@ -1592,6 +1616,8 @@ def test_chart_data_cache_hit_refreshes_when_multi_model_cache_is_stale(monkeypa
def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch):
import asyncio
local_date = datetime.now(timezone.utc).date().isoformat()
stale_date = (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat()
class FakeCache:
def get_city_cache(self, kind, city):
@@ -1600,7 +1626,7 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
"payload": {
"name": city,
"display_name": "Lucknow",
"local_date": "2026-06-21",
"local_date": local_date,
"local_time": "13:30",
"temp_symbol": "°C",
"current": {"temp": 38.0, "max_so_far": 38.0},
@@ -1608,11 +1634,18 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
"airport_primary": {"temp": 38.0, "max_so_far": 38.0},
"forecast": {
"today_high": 36.2,
"daily": [{"date": "2026-06-14", "max_temp": 36.2}],
"daily": [{"date": stale_date, "max_temp": 36.2}],
},
"deb": {
"prediction": 36.0,
"raw_prediction": 36.0,
"hourly_path": {
"times": ["13:00", "14:00"],
"temps": [36.0, 37.0],
},
},
"deb": {"prediction": 36.0, "raw_prediction": 36.0},
"multi_model_daily": {
"2026-06-14": {"models": {"Open-Meteo": 36.2}},
stale_date: {"models": {"Open-Meteo": 36.2}},
},
"multi_model": {},
"hourly": {"times": ["13:00"], "temps": [36.0]},
@@ -1649,16 +1682,16 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
cache_key: {
"data": {
"hourly_times": [
"2026-06-21T13:00",
"2026-06-21T14:00",
"2026-06-21T15:00",
f"{local_date}T13:00",
f"{local_date}T14:00",
f"{local_date}T15:00",
],
"hourly_forecasts": {
"ECMWF": [38.7, 39.0, 38.0],
"GFS": [42.0, 44.1, 43.0],
},
"daily_forecasts": {
"2026-06-21": {"ECMWF": 39.0, "GFS": 44.1},
local_date: {"ECMWF": 39.0, "GFS": 44.1},
},
"forecasts": {"ECMWF": 39.0, "GFS": 44.1},
}
@@ -1681,10 +1714,12 @@ def test_chart_data_floors_stale_forecast_and_deb_with_observed_high(monkeypatch
assert payload["forecast"]["today_high"] >= 38.0
assert payload["deb"]["prediction"] >= 38.0
assert payload["multi_model_daily"]["2026-06-21"]["models"]["GFS"] == 44.1
assert max(payload["deb"]["hourly_path"]["temps"]) >= 38.0
assert payload["multi_model_daily"][local_date]["models"]["GFS"] == 44.1
assert detail["forecast"]["today_high"] >= 38.0
assert detail["overview"]["deb_prediction"] >= 38.0
assert detail["multi_model_daily"]["2026-06-21"]["models"]["GFS"] == 44.1
assert max(detail["deb"]["hourly_path"]["temps"]) >= 38.0
assert detail["multi_model_daily"][local_date]["models"]["GFS"] == 44.1
def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch):
+3 -2
View File
@@ -36,6 +36,7 @@ from src.data_collection.country_networks import build_country_network_snapshot
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.city_time import get_city_utc_offset_seconds
from src.data_collection.forecast_source_bundle import ensure_multi_model_hourly_payload
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
from src.database.runtime_state import IntradayPathSnapshotRepository
from web.services.city_payloads import (
build_city_chart_detail_payload as _city_chart_payload_detail,
@@ -894,7 +895,7 @@ def _analyze(
current_forecasts: Dict[str, float] = {}
if om_today is not None:
current_forecasts["Open-Meteo"] = om_today
for m, v in mm.get("forecasts", {}).items():
for m, v in multi_model_forecasts_for_local_date(mm, local_date_str).items():
if v is not None and not _is_excluded_model_name(m):
temp_val = _sf(v)
if temp_val is not None:
@@ -1924,7 +1925,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
current_forecasts: Dict[str, float] = {}
if om_today is not None:
current_forecasts["Open-Meteo"] = om_today
for m, v in mm.get("forecasts", {}).items():
for m, v in multi_model_forecasts_for_local_date(mm, local_date_str).items():
if v is not None and not _is_excluded_model_name(m):
temp_val = _sf(v)
if temp_val is not None:
+37 -25
View File
@@ -18,6 +18,7 @@ from src.data_collection.forecast_source_bundle import (
_multi_model_cache_key,
fetch_open_meteo_forecast_bundle,
)
from src.data_collection.multi_model_freshness import multi_model_forecasts_for_local_date
import web.routes as legacy_routes
from web.analysis_service import _runway_history_temp_for_city
from web.services.canonical_temperature import build_city_weather_from_canonical
@@ -868,6 +869,7 @@ def _floor_chart_forecast_with_observed_high(payload: Dict[str, Any]) -> Dict[st
rounded_floor = round(float(observed_floor), 1)
next_payload = _floor_forecast_today_high(next_payload, local_date, rounded_floor)
next_payload = _floor_deb_prediction(next_payload, rounded_floor)
next_payload = _floor_deb_hourly_path(next_payload, rounded_floor)
next_payload = _floor_multi_model_daily_deb(next_payload, local_date, rounded_floor)
next_payload = _floor_probability_mu(next_payload, rounded_floor)
return next_payload
@@ -923,31 +925,13 @@ def _first_float(block: Dict[str, Any], keys: Tuple[str, ...]) -> Optional[float
def _multi_model_daily_models_for_date(multi_model: Any, local_date: str) -> Dict[str, float]:
if not isinstance(multi_model, dict) or not local_date:
return {}
models: Dict[str, float] = {}
daily = multi_model.get("daily_forecasts") if isinstance(multi_model.get("daily_forecasts"), dict) else {}
day_models = daily.get(local_date) if isinstance(daily, dict) else {}
if isinstance(day_models, dict):
for model, value in day_models.items():
parsed = _float_or_none(value)
if parsed is not None:
models[str(model)] = round(parsed, 1)
hourly_models = _multi_model_daily_models_from_hourly(multi_model, local_date)
for model, value in hourly_models.items():
current = models.get(model)
models[model] = value if current is None else round(max(current, value), 1)
if not models:
forecasts = multi_model.get("forecasts") if isinstance(multi_model.get("forecasts"), dict) else {}
for model, value in forecasts.items():
parsed = _float_or_none(value)
if parsed is not None:
models[str(model)] = round(parsed, 1)
return models
return {
model: round(value, 1)
for model, value in multi_model_forecasts_for_local_date(
multi_model,
local_date,
).items()
}
def _multi_model_daily_models_from_hourly(multi_model: Dict[str, Any], local_date: str) -> Dict[str, float]:
@@ -1062,6 +1046,34 @@ def _floor_deb_prediction(payload: Dict[str, Any], observed_floor: float) -> Dic
return next_payload
def _floor_deb_hourly_path(payload: Dict[str, Any], observed_floor: float) -> Dict[str, Any]:
deb = payload.get("deb") if isinstance(payload.get("deb"), dict) else {}
path = deb.get("hourly_path") if isinstance(deb.get("hourly_path"), dict) else {}
temps = path.get("temps") if isinstance(path.get("temps"), list) else []
parsed_temps = [_float_or_none(value) for value in temps]
valid_temps = [value for value in parsed_temps if value is not None]
if not valid_temps:
return payload
path_max = max(valid_temps)
if path_max >= observed_floor:
return payload
offset = observed_floor - path_max
next_payload = deepcopy(payload)
next_deb = dict(next_payload.get("deb") or {})
next_path = dict(next_deb.get("hourly_path") or {})
next_path["temps"] = [
round(value + offset, 1) if value is not None else raw_value
for raw_value, value in zip(temps, parsed_temps)
]
next_path["observed_floor_applied"] = True
next_path["observed_floor_offset"] = round(offset, 1)
next_deb["hourly_path"] = next_path
next_deb["observed_floor_applied"] = True
next_payload["deb"] = next_deb
return next_payload
def _floor_multi_model_daily_deb(
payload: Dict[str, Any],
local_date: str,