mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-29 02:41:41 +00:00
refactor(rain): enhance fetch_precip_series_nws with caching support
- Added cache_ttl parameter to fetch_precip_series_nws to enable caching of results for improved performance. - Implemented logic to reuse cached results based on location and cache expiration. - Updated tests to ensure proper handling of cache_ttl without causing errors. - Refactored WorldCupFastcastClient to streamline connection handling and improve readability.
This commit is contained in:
@@ -105,9 +105,9 @@ class WorldCupFastcastClient:
|
||||
|
||||
async def _connect_once(self) -> None:
|
||||
headers = {"Origin": ORIGIN, "User-Agent": USER_AGENT}
|
||||
async with aiohttp.ClientSession(headers=headers) as session:
|
||||
timeout = aiohttp.ClientTimeout(total=self.connect_timeout)
|
||||
async with session.get(WEBSOCKET_HOST_URL, timeout=timeout) as resp:
|
||||
timeout = aiohttp.ClientTimeout(total=self.connect_timeout)
|
||||
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
|
||||
async with session.get(WEBSOCKET_HOST_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
host = await resp.json()
|
||||
|
||||
@@ -117,7 +117,7 @@ class WorldCupFastcastClient:
|
||||
raise ValueError("fastcast websockethost returned no ip")
|
||||
|
||||
url = f"wss://{ip}:{port}/FastcastService/pubsub/profiles/{self.profile}?TrafficId={uuid.uuid4()}"
|
||||
async with session.ws_connect(url, timeout=self.connect_timeout, heartbeat=25, origin=ORIGIN) as ws:
|
||||
async with session.ws_connect(url, heartbeat=25, origin=ORIGIN) as ws:
|
||||
self.logger.info("Fastcast connected (topic=%s)", self.topic)
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
|
||||
@@ -486,6 +486,7 @@ def fetch_precip_series_nws(
|
||||
timeout: int = 10,
|
||||
logger: Any = None,
|
||||
pop_floor: int = 50,
|
||||
cache_ttl: float = 0.0,
|
||||
) -> Optional[dict]:
|
||||
"""Build a precip nowcast series from the NWS gridpoint forecast (US only).
|
||||
|
||||
@@ -499,7 +500,16 @@ def fetch_precip_series_nws(
|
||||
probability rather than snapping to coarse 6-hour QPF boundaries, and a trace
|
||||
of QPF at a low chance is not reported as rain. Times are naive UTC ISO strings
|
||||
(they only need to be self-consistent: the nowcast works on relative minutes).
|
||||
|
||||
When cache_ttl > 0, a fresh prior result for the same rounded location is reused
|
||||
(shared bounded cache with fetch_precip_series).
|
||||
"""
|
||||
cache_key = (round(lat, 2), round(lon, 2), "nws")
|
||||
if cache_ttl > 0:
|
||||
hit = _SERIES_CACHE.get(cache_key)
|
||||
if hit is not None and (time.time() - hit[0]) < cache_ttl:
|
||||
return hit[1]
|
||||
|
||||
headers = {"User-Agent": "(meshcore-bot, weather-nowcast)", "Accept": "application/geo+json"}
|
||||
try:
|
||||
pts = session.get(
|
||||
@@ -547,7 +557,7 @@ def fetch_precip_series_nws(
|
||||
precip.append(amt)
|
||||
codes.append(_nws_weather_code(wx.get(h)) if amt else None)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"times": times,
|
||||
"precip": precip,
|
||||
"codes": codes,
|
||||
@@ -556,6 +566,11 @@ def fetch_precip_series_nws(
|
||||
"current_code": codes[0] if codes else None,
|
||||
"step": 60,
|
||||
}
|
||||
if cache_ttl > 0:
|
||||
if len(_SERIES_CACHE) >= _SERIES_CACHE_CAP:
|
||||
_SERIES_CACHE.pop(next(iter(_SERIES_CACHE)))
|
||||
_SERIES_CACHE[cache_key] = (time.time(), result)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -15,7 +15,7 @@ World Cup data is also reachable year-round through the `sports` command
|
||||
front end with live scores, standings, and nation lookups.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..clients.espn_client import ESPNClient
|
||||
from ..clients.sports_mappings import SPORT_EMOJIS
|
||||
@@ -50,8 +50,6 @@ class WorldCupCommand(BaseCommand):
|
||||
{"name": "nation", "description": "A nation's matches (e.g. brazil, usa)"},
|
||||
]
|
||||
|
||||
espn_client: Optional[ESPNClient] = None
|
||||
|
||||
def __init__(self, bot: "MeshCoreBot"):
|
||||
"""Initialize the World Cup command with an ESPN client and data helper."""
|
||||
super().__init__(bot)
|
||||
@@ -62,7 +60,7 @@ class WorldCupCommand(BaseCommand):
|
||||
|
||||
cache_ttl_minutes = self.get_config_value("Worldcup_Command", "cache_ttl_minutes", fallback=360, value_type="int")
|
||||
|
||||
self.espn_client = ESPNClient(logger=self.logger, timeout=self.url_timeout)
|
||||
self.espn_client: ESPNClient = ESPNClient(logger=self.logger, timeout=self.url_timeout)
|
||||
self.wc_data = WorldCupData(self.espn_client, logger=self.logger, cache_ttl=cache_ttl_minutes * 60)
|
||||
|
||||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||||
|
||||
@@ -75,6 +75,78 @@ def build_service(series, monkeypatch, *, overrides=None):
|
||||
return service, sends
|
||||
|
||||
|
||||
# --- NWS path (WeatherService passes cache_ttl) ------------------------------
|
||||
|
||||
def test_nws_path_accepts_cache_ttl_without_crashing(monkeypatch):
|
||||
"""Proactive poll calls fetch_precip_series_nws(..., cache_ttl=...); must not TypeError."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
base = now.replace(minute=0, second=0, microsecond=0)
|
||||
nws_series = {
|
||||
"times": [(base + timedelta(hours=i)).isoformat(timespec="minutes") for i in range(3)],
|
||||
"precip": [0.0, 0.5, 0.5],
|
||||
"codes": [None, 61, 61],
|
||||
"now": now.isoformat(timespec="minutes"),
|
||||
"current_precip": 0.0,
|
||||
"current_code": None,
|
||||
"step": 60,
|
||||
}
|
||||
nws_kwargs: list[dict] = []
|
||||
open_meteo_called: list[bool] = []
|
||||
|
||||
def fake_nws(*_a, **kwargs):
|
||||
nws_kwargs.append(kwargs)
|
||||
return nws_series
|
||||
|
||||
def fake_open_meteo(*_a, **_k):
|
||||
open_meteo_called.append(True)
|
||||
return None
|
||||
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.add_section("Weather")
|
||||
cfg.add_section("Weather_Service")
|
||||
cfg.set("Weather_Service", "my_position_lat", "36.16")
|
||||
cfg.set("Weather_Service", "my_position_lon", "-86.78")
|
||||
cfg.set("Weather_Service", "rain_nowcast_enabled", "true")
|
||||
cfg.set("Weather_Service", "rain_channel", "weather")
|
||||
cfg.set("Weather_Service", "rain_nowcast_cache_seconds", "300")
|
||||
|
||||
bot = Mock()
|
||||
bot.logger = Mock()
|
||||
bot.config = cfg
|
||||
bot.db_manager = Mock()
|
||||
|
||||
sends: list[tuple[str, str]] = []
|
||||
|
||||
async def _send(channel, text, **kwargs):
|
||||
sends.append((channel, text))
|
||||
return True
|
||||
|
||||
bot.command_manager.send_channel_message = _send
|
||||
|
||||
monkeypatch.setattr(
|
||||
"modules.service_plugins.weather_service.fetch_precip_series_nws", fake_nws
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"modules.service_plugins.weather_service.fetch_precip_series", fake_open_meteo
|
||||
)
|
||||
|
||||
service = WeatherService(bot)
|
||||
service.api_session = Mock()
|
||||
service._cached_rain_location = "Nashville, TN"
|
||||
service.get_mesh_flood_scope = Mock(return_value=None)
|
||||
|
||||
asyncio.run(service._check_rain_nowcast())
|
||||
|
||||
assert nws_kwargs, "NWS fetch should have been tried"
|
||||
assert nws_kwargs[0].get("cache_ttl") == 300
|
||||
assert open_meteo_called == [], "Open-Meteo fallback should not run when NWS succeeds"
|
||||
assert len(sends) == 1
|
||||
assert "Heads up" in sends[0][1]
|
||||
bot.logger.error.assert_not_called()
|
||||
|
||||
|
||||
# --- probability gate -------------------------------------------------------
|
||||
|
||||
def test_incoming_above_threshold_pushes(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user