Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 71 additions & 2 deletions src/accessiweather/weather_client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
95 changes: 94 additions & 1 deletion tests/test_split_notification_timers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Loading