diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a03f53..49a5c27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Fixed +- **Adaptive Pirate Weather minutely polling** — minutely precipitation checks now stay on your normal refresh cadence when the next few hours look dry, then speed up automatically when the hourly forecast suggests rain is likely soon (#565) + ## [0.4.5] - 2026-03-26 ### Added diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py index e0e8f757..4511b253 100644 --- a/src/accessiweather/weather_client_base.py +++ b/src/accessiweather/weather_client_base.py @@ -7,7 +7,7 @@ import logging import os from collections.abc import Sequence -from datetime import datetime +from datetime import UTC, datetime, timedelta import httpx @@ -53,6 +53,10 @@ logger = logging.getLogger(__name__) +MINUTELY_FAST_POLL_INTERVAL = timedelta(minutes=5) +MINUTELY_ADAPTIVE_PRECIP_PROBABILITY_THRESHOLD = 30 +MINUTELY_ADAPTIVE_LOOKAHEAD_HOURS = 6 + class WeatherClient: """Simple async weather API client.""" @@ -118,6 +122,8 @@ def __init__( # Cache of previous alerts per location key (for lifecycle diff) self._previous_alerts: dict[str, WeatherAlerts] = {} + self._latest_weather_by_location: dict[str, WeatherData] = {} + self._last_minutely_poll_by_location: dict[str, datetime] = {} @property def visual_crossing_api_key(self) -> str: @@ -190,6 +196,60 @@ def _location_key(self, location: Location) -> str: """Generate a unique key for a location to track in-flight requests.""" return f"{location.name}:{location.latitude:.4f},{location.longitude:.4f}" + def _utcnow(self) -> datetime: + """Return the current UTC time for poll-throttling decisions.""" + return datetime.now(UTC) + + def _remember_weather_data(self, weather_data: WeatherData) -> None: + """Store the latest full-weather snapshot for adaptive polling decisions.""" + self._latest_weather_by_location[self._location_key(weather_data.location)] = weather_data + + def _get_latest_weather_data(self, location: Location) -> WeatherData | None: + """Return the freshest known weather data for a location.""" + latest = self._latest_weather_by_location.get(self._location_key(location)) + if latest is not None: + return latest + return self.get_cached_weather(location) + + def _should_use_fast_minutely_poll(self, location: Location) -> bool: + """Return True when the next few forecast hours suggest likely precipitation.""" + weather_data = self._get_latest_weather_data(location) + hourly = weather_data.hourly_forecast if weather_data else None + if not hourly or not hourly.has_data(): + return False + + now = self._utcnow() + lookahead_deadline = now + timedelta(hours=MINUTELY_ADAPTIVE_LOOKAHEAD_HOURS) + for period in hourly.get_next_hours(MINUTELY_ADAPTIVE_LOOKAHEAD_HOURS): + probability = getattr(period, "precipitation_probability", None) + start_time = getattr(period, "start_time", None) + if probability is None or probability < MINUTELY_ADAPTIVE_PRECIP_PROBABILITY_THRESHOLD: + continue + if start_time is None: + return True + aware_start = ( + start_time.replace(tzinfo=UTC) + if start_time.tzinfo is None + else start_time.astimezone(UTC) + ) + if aware_start <= lookahead_deadline: + return True + return False + + def _should_fetch_minutely_precipitation(self, location: Location) -> bool: + """Throttle Pirate Weather minutely polling based on forecast precipitation risk.""" + normal_interval = timedelta( + minutes=max(1, int(getattr(self.settings, "update_interval_minutes", 10))) + ) + target_interval = normal_interval + if self._should_use_fast_minutely_poll(location): + target_interval = min(normal_interval, MINUTELY_FAST_POLL_INTERVAL) + + last_poll = self._last_minutely_poll_by_location.get(self._location_key(location)) + if last_poll is None: + return True + return self._utcnow() - last_poll >= target_interval + async def _fetch_nws_cancel_references(self) -> set[str]: """Fetch recent NWS cancel references for verifying genuine cancellations.""" return await nws_client.fetch_nws_cancel_references( @@ -531,10 +591,15 @@ async def get_notification_event_data(self, location: Location) -> WeatherData: ): _want_start = getattr(self.settings, "notify_minutely_precipitation_start", False) _want_stop = getattr(self.settings, "notify_minutely_precipitation_stop", False) - if _want_start or _want_stop: + if (_want_start or _want_stop) and self._should_fetch_minutely_precipitation( + location + ): weather_data.minutely_precipitation = await self._get_pirate_weather_minutely( location ) + self._last_minutely_poll_by_location[self._location_key(location)] = ( + self._utcnow() + ) loc_key = self._location_key(location) previous_alerts = self._previous_alerts.get(loc_key) @@ -829,8 +894,10 @@ async def _do_fetch_weather_data( cached = self.offline_cache.load(location) if cached: logger.info(f"Using cached weather data for {location.name}") + self._remember_weather_data(cached) return cached + self._remember_weather_data(weather_data) return weather_data async def _fetch_smart_auto_source( @@ -1099,6 +1166,7 @@ def _handle_all_sources_failed( cached.stale = True cached.stale_reason = "All weather sources failed" logger.info(f"Returning stale cached data for {location.name}") + self._remember_weather_data(cached) return cached # No cache available, return empty data with attribution @@ -1503,6 +1571,7 @@ async def _enrich_with_visual_crossing_history( logger.debug("Failed to fetch history from Visual Crossing: %s", exc) def _persist_weather_data(self, location: Location, weather_data: WeatherData) -> None: + self._remember_weather_data(weather_data) if not self.offline_cache: return if not weather_data.has_any_data(): diff --git a/tests/test_split_notification_timers.py b/tests/test_split_notification_timers.py index 38075e68..926a6a47 100644 --- a/tests/test_split_notification_timers.py +++ b/tests/test_split_notification_timers.py @@ -11,11 +11,18 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest -from accessiweather.models import Location, WeatherAlerts, WeatherData +from accessiweather.models import ( + HourlyForecast, + HourlyForecastPeriod, + Location, + WeatherAlerts, + WeatherData, +) from accessiweather.notifications.notification_event_manager import summarize_discussion_change # --------------------------------------------------------------------------- @@ -534,3 +541,89 @@ async def test_exception_returns_empty_alerts(self, client, us_location): assert result.alerts is not None assert len(result.alerts.alerts) == 0 + + @pytest.mark.asyncio + async def test_minutely_precipitation_fetch_uses_normal_interval_when_clear( + self, client, intl_location + ): + settings = client.settings + settings.update_interval_minutes = 30 + settings.notify_minutely_precipitation_start = True + settings.notify_minutely_precipitation_stop = True + client.data_source = "pirateweather" + client.pirate_weather_client = MagicMock() + client.pirate_weather_client.get_current_conditions = AsyncMock(return_value=MagicMock()) + client.pirate_weather_client.get_alerts = AsyncMock(return_value=WeatherAlerts(alerts=[])) + client._get_pirate_weather_minutely = AsyncMock(return_value=MagicMock()) + + now = datetime(2026, 4, 7, 12, 0, tzinfo=UTC) + client._latest_weather_by_location[client._location_key(intl_location)] = WeatherData( + location=intl_location, + hourly_forecast=HourlyForecast( + periods=[ + HourlyForecastPeriod( + start_time=now + timedelta(hours=1), + precipitation_probability=10, + ) + ] + ), + ) + + client._utcnow = MagicMock( + side_effect=[ + now, + now, + now + timedelta(minutes=1), + now + timedelta(minutes=31), + now + timedelta(minutes=31), + ] + ) + + await client.get_notification_event_data(intl_location) + await client.get_notification_event_data(intl_location) + await client.get_notification_event_data(intl_location) + + assert client._get_pirate_weather_minutely.await_count == 2 + + @pytest.mark.asyncio + async def test_minutely_precipitation_fetch_uses_fast_interval_when_rain_is_likely( + self, client, intl_location + ): + settings = client.settings + settings.update_interval_minutes = 30 + settings.notify_minutely_precipitation_start = True + settings.notify_minutely_precipitation_stop = True + client.data_source = "pirateweather" + client.pirate_weather_client = MagicMock() + client.pirate_weather_client.get_current_conditions = AsyncMock(return_value=MagicMock()) + client.pirate_weather_client.get_alerts = AsyncMock(return_value=WeatherAlerts(alerts=[])) + client._get_pirate_weather_minutely = AsyncMock(return_value=MagicMock()) + + now = datetime(2026, 4, 7, 12, 0, tzinfo=UTC) + client._latest_weather_by_location[client._location_key(intl_location)] = WeatherData( + location=intl_location, + hourly_forecast=HourlyForecast( + periods=[ + HourlyForecastPeriod( + start_time=now + timedelta(hours=1), + precipitation_probability=40, + ) + ] + ), + ) + + client._utcnow = MagicMock( + side_effect=[ + now, + now, + now + timedelta(minutes=4), + now + timedelta(minutes=6), + now + timedelta(minutes=6), + ] + ) + + await client.get_notification_event_data(intl_location) + await client.get_notification_event_data(intl_location) + await client.get_notification_event_data(intl_location) + + assert client._get_pirate_weather_minutely.await_count == 2