Reject stale Open-Meteo forecast cache
This commit is contained in:
@@ -2,7 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.data_collection.multi_model_freshness import multi_model_has_current_window
|
||||
from src.data_collection.multi_model_freshness import (
|
||||
multi_model_has_current_window,
|
||||
open_meteo_forecast_has_current_window,
|
||||
)
|
||||
|
||||
|
||||
def _open_meteo_cache_key(
|
||||
@@ -48,8 +51,9 @@ def _read_open_meteo_bundle_from_cache(
|
||||
with collector._open_meteo_cache_lock:
|
||||
om_cached = collector._open_meteo_cache.get(om_key)
|
||||
|
||||
if om_cached and isinstance(om_cached.get("data"), dict):
|
||||
results["open-meteo"] = dict(om_cached["data"])
|
||||
om_data = om_cached.get("data") if isinstance(om_cached, dict) else None
|
||||
if isinstance(om_data, dict) and open_meteo_forecast_has_current_window(om_data):
|
||||
results["open-meteo"] = dict(om_data)
|
||||
if include_multi_model:
|
||||
mm_key = _multi_model_cache_key(
|
||||
collector,
|
||||
|
||||
@@ -63,6 +63,26 @@ def multi_model_has_current_window(multi_model: Any, *, today: Optional[date] =
|
||||
return max(parsed_dates) >= today
|
||||
|
||||
|
||||
def open_meteo_forecast_has_current_window(
|
||||
forecast: Any,
|
||||
*,
|
||||
today: Optional[date] = None,
|
||||
) -> bool:
|
||||
"""Return False when cached Open-Meteo forecast data is definitely older than today."""
|
||||
if not isinstance(forecast, dict):
|
||||
return False
|
||||
today = today or datetime.now(timezone.utc).date()
|
||||
raw_date_values = []
|
||||
daily = forecast.get("daily") if isinstance(forecast.get("daily"), dict) else {}
|
||||
raw_date_values.extend(daily.get("time") or [])
|
||||
hourly = forecast.get("hourly") if isinstance(forecast.get("hourly"), dict) else {}
|
||||
raw_date_values.extend(hourly.get("time") or [])
|
||||
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
|
||||
|
||||
@@ -6,7 +6,10 @@ 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.data_collection.multi_model_freshness import (
|
||||
multi_model_has_current_window,
|
||||
open_meteo_forecast_has_current_window,
|
||||
)
|
||||
from src.utils.metrics import record_source_call
|
||||
|
||||
|
||||
@@ -528,16 +531,18 @@ class NwsOpenMeteoSourceMixin:
|
||||
logger.debug(f"Open-Meteo 冷却期中,跳过请求,还需 {remaining}s")
|
||||
with self._open_meteo_cache_lock:
|
||||
stale = self._open_meteo_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 open_meteo_forecast_has_current_window(stale_data):
|
||||
record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale["data"])
|
||||
return dict(stale_data)
|
||||
# Memory miss: force-reload from disk and retry once
|
||||
self._load_open_meteo_disk_cache()
|
||||
with self._open_meteo_cache_lock:
|
||||
stale2 = self._open_meteo_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 open_meteo_forecast_has_current_window(stale2_data):
|
||||
record_source_call("open_meteo", "forecast", "disk_fallback", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(stale2["data"])
|
||||
return dict(stale2_data)
|
||||
record_source_call("open_meteo", "forecast", "cooldown_skip", (time.perf_counter() - started) * 1000.0)
|
||||
return None
|
||||
with self._open_meteo_cache_lock:
|
||||
@@ -547,6 +552,11 @@ class NwsOpenMeteoSourceMixin:
|
||||
and now_ts - float(cached.get("t", 0)) < self.open_meteo_cache_ttl_sec
|
||||
):
|
||||
cached_data = cached.get("data")
|
||||
if isinstance(cached_data, dict):
|
||||
if not open_meteo_forecast_has_current_window(cached_data):
|
||||
self._open_meteo_cache.pop(cache_key, None)
|
||||
record_source_call("open_meteo", "forecast", "expired_cache_skip", (time.perf_counter() - started) * 1000.0)
|
||||
cached_data = None
|
||||
if isinstance(cached_data, dict):
|
||||
record_source_call("open_meteo", "forecast", "cache_hit", (time.perf_counter() - started) * 1000.0)
|
||||
return dict(cached_data)
|
||||
@@ -672,8 +682,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
logger.error(f"Open-Meteo forecast failed: {e}")
|
||||
with self._open_meteo_cache_lock:
|
||||
stale = self._open_meteo_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 open_meteo_forecast_has_current_window(stale_data):
|
||||
fallback = dict(stale_data)
|
||||
fallback["stale_cache"] = True
|
||||
record_source_call("open_meteo", "forecast", "stale_cache", (time.perf_counter() - started) * 1000.0)
|
||||
return fallback
|
||||
|
||||
@@ -513,6 +513,65 @@ def test_fetch_multi_model_ignores_cache_when_dates_are_stale(monkeypatch, tmp_p
|
||||
assert result["forecasts"]["GFS"] == 38.6
|
||||
|
||||
|
||||
def test_fetch_open_meteo_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)}:14:c"
|
||||
collector._open_meteo_cache[cache_key] = {
|
||||
"t": time.time(),
|
||||
"data": {
|
||||
"source": "open-meteo",
|
||||
"daily": {
|
||||
"time": old_dates,
|
||||
"temperature_2m_max": [24.5, 26.6],
|
||||
},
|
||||
"hourly": {"time": [f"{old_dates[0]}T15:00"], "temperature_2m": [24.0]},
|
||||
},
|
||||
}
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"current_weather": {"temperature": 32.0},
|
||||
"utc_offset_seconds": 7200,
|
||||
"timezone": "Europe/Paris",
|
||||
"daily": {
|
||||
"time": fresh_dates,
|
||||
"temperature_2m_max": [39.2, 33.0],
|
||||
"sunrise": [f"{fresh_dates[0]}T05:30", f"{fresh_dates[1]}T05:31"],
|
||||
"sunset": [f"{fresh_dates[0]}T21:55", f"{fresh_dates[1]}T21:55"],
|
||||
"sunshine_duration": [36000, 33000],
|
||||
},
|
||||
"hourly": {
|
||||
"time": [f"{fresh_dates[0]}T15:00", f"{fresh_dates[0]}T16:00"],
|
||||
"temperature_2m": [38.8, 39.2],
|
||||
},
|
||||
}
|
||||
|
||||
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_from_open_meteo(48.9694, 2.4414)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert result["daily"]["time"][:2] == fresh_dates
|
||||
assert result["daily"]["temperature_2m_max"][0] == 39.2
|
||||
|
||||
|
||||
def test_multi_model_hourly_parser():
|
||||
from src.data_collection.nws_open_meteo_sources import _parse_open_meteo_multi_model_hourly
|
||||
|
||||
|
||||
Reference in New Issue
Block a user