From 781ac66a4706aa1b5d50a64b0cffaf1a5c40114f Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 18 Mar 2026 21:43:33 +0000 Subject: [PATCH] feat: add minutely precipitation notifications from Pirate Weather (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse PW minutely block for per-minute precip intensity/probability - Detect precipitation transitions (dry→wet, wet→dry) - Generate notifications: 'Rain starting in X minutes', 'Precipitation ending in X minutes' - Add settings: minutely_precip_alerts (on/off), minutely_precip_threshold_minutes - Wire into notification event manager with cooldown support - Screen reader friendly notification text - Add comprehensive tests --- CHANGELOG.md | 1 + src/accessiweather/models/__init__.py | 4 + src/accessiweather/models/config.py | 12 ++ src/accessiweather/models/weather.py | 25 +++ src/accessiweather/notifications/__init__.py | 10 + .../notifications/minutely_precipitation.py | 176 ++++++++++++++++++ .../notification_event_manager.py | 80 +++++++- .../ui/dialogs/settings_dialog.py | 24 +++ .../ui/main_window_notification_events.py | 14 +- src/accessiweather/weather_client_base.py | 35 ++++ tests/test_models.py | 28 +++ tests/test_notification_event_manager.py | 169 ++++++++++++++++- 12 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 src/accessiweather/notifications/minutely_precipitation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9003ff599..2feb105c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Main window integration for displaying discussion summaries. - AI summarization button in the nationwide dialog. - Shared `NationalDiscussionService` instance with caching. +- Added opt-in Pirate Weather minutely precipitation notifications, including "starting soon" and "stopping soon" toggles that can announce transitions like "Rain starting in 12 minutes." - Bundled `prismatoid`/`prism` in PyInstaller nightly builds (PR #294). - Added missing tests for `national_discussion_service.py` to meet coverage gate requirements. - Updated Antfarm to v0.2.2 and configured feature-dev workflow for 80% diff-coverage. diff --git a/src/accessiweather/models/__init__.py b/src/accessiweather/models/__init__.py index 792dc6f3a..12a1b39c5 100644 --- a/src/accessiweather/models/__init__.py +++ b/src/accessiweather/models/__init__.py @@ -25,6 +25,8 @@ HourlyForecastPeriod, HourlyUVIndex, Location, + MinutelyPrecipitationForecast, + MinutelyPrecipitationPoint, SourceAttribution, SourceData, TrendInsight, @@ -40,6 +42,8 @@ "HourlyForecast", "HourlyAirQuality", "HourlyUVIndex", + "MinutelyPrecipitationPoint", + "MinutelyPrecipitationForecast", "TrendInsight", "EnvironmentalConditions", "AviationData", diff --git a/src/accessiweather/models/config.py b/src/accessiweather/models/config.py index 9273a2032..bd7839c66 100644 --- a/src/accessiweather/models/config.py +++ b/src/accessiweather/models/config.py @@ -40,6 +40,8 @@ # Event notifications "notify_discussion_update", "notify_severe_risk_change", + "notify_minutely_precipitation_start", + "notify_minutely_precipitation_stop", # GitHub settings "github_backend_url", "github_app_id", @@ -115,6 +117,8 @@ class AppSettings: # Event-based notifications notify_discussion_update: bool = True notify_severe_risk_change: bool = False + notify_minutely_precipitation_start: bool = False + notify_minutely_precipitation_stop: bool = False github_backend_url: str = "" github_app_id: str = "" github_app_private_key: str = "" @@ -400,6 +404,8 @@ def to_dict(self) -> dict: "show_nationwide_location": self.show_nationwide_location, "notify_discussion_update": self.notify_discussion_update, "notify_severe_risk_change": self.notify_severe_risk_change, + "notify_minutely_precipitation_start": self.notify_minutely_precipitation_start, + "notify_minutely_precipitation_stop": self.notify_minutely_precipitation_stop, "github_backend_url": self.github_backend_url, "alert_radius_type": self.alert_radius_type, "alert_notifications_enabled": self.alert_notifications_enabled, @@ -476,6 +482,12 @@ def from_dict(cls, data: dict) -> AppSettings: show_nationwide_location=cls._as_bool(data.get("show_nationwide_location"), True), notify_discussion_update=cls._as_bool(data.get("notify_discussion_update"), True), notify_severe_risk_change=cls._as_bool(data.get("notify_severe_risk_change"), False), + notify_minutely_precipitation_start=cls._as_bool( + data.get("notify_minutely_precipitation_start"), False + ), + notify_minutely_precipitation_stop=cls._as_bool( + data.get("notify_minutely_precipitation_stop"), False + ), github_backend_url=data.get("github_backend_url", ""), alert_radius_type=data.get("alert_radius_type", "county"), alert_notifications_enabled=cls._as_bool(data.get("alert_notifications_enabled"), True), diff --git a/src/accessiweather/models/weather.py b/src/accessiweather/models/weather.py index beda4e0a4..a0cdb6e39 100644 --- a/src/accessiweather/models/weather.py +++ b/src/accessiweather/models/weather.py @@ -406,6 +406,29 @@ def _to_timestamp(dt: datetime | None, *, as_utc: bool) -> float | None: return fallback[:count] +@dataclass +class MinutelyPrecipitationPoint: + """A single minute of precipitation guidance.""" + + time: datetime + precipitation_intensity: float | None = None + precipitation_probability: float | None = None + precipitation_type: str | None = None + + +@dataclass +class MinutelyPrecipitationForecast: + """Short-range precipitation guidance from a minutely provider.""" + + summary: str | None = None + icon: str | None = None + points: list[MinutelyPrecipitationPoint] = field(default_factory=list) + + def has_data(self) -> bool: + """Return True when at least one minutely point is available.""" + return len(self.points) > 0 + + @dataclass class TrendInsight: """Summary of a metric trend over a timeframe.""" @@ -510,6 +533,7 @@ class WeatherData: daily_history: list[ForecastPeriod] = field(default_factory=list) discussion: str | None = None discussion_issuance_time: datetime | None = None # NWS AFD issuance time for update detection + minutely_precipitation: MinutelyPrecipitationForecast | None = None alerts: WeatherAlerts | None = None environmental: EnvironmentalConditions | None = None aviation: AviationData | None = None @@ -544,6 +568,7 @@ def has_any_data(self) -> bool: self.current and self.current.has_data(), self.forecast and self.forecast.has_data(), self.hourly_forecast and self.hourly_forecast.has_data(), + self.minutely_precipitation and self.minutely_precipitation.has_data(), self.alerts and self.alerts.has_alerts(), self.environmental and self.environmental.has_data(), self.aviation and self.aviation.has_taf(), diff --git a/src/accessiweather/notifications/__init__.py b/src/accessiweather/notifications/__init__.py index 42b403c42..4f97dafa7 100644 --- a/src/accessiweather/notifications/__init__.py +++ b/src/accessiweather/notifications/__init__.py @@ -12,6 +12,12 @@ from .alert_sound_mapper import choose_sound_event, get_candidate_sound_events # Event-based notifications +from .minutely_precipitation import ( + MinutelyPrecipitationTransition, + build_minutely_transition_signature, + detect_minutely_precipitation_transition, + parse_pirate_weather_minutely_block, +) from .notification_event_manager import ( NotificationEvent, NotificationEventManager, @@ -37,6 +43,10 @@ # Alert sound mapping "choose_sound_event", "get_candidate_sound_events", + "MinutelyPrecipitationTransition", + "parse_pirate_weather_minutely_block", + "detect_minutely_precipitation_transition", + "build_minutely_transition_signature", # Sound player "get_available_sound_packs", "get_sound_file", diff --git a/src/accessiweather/notifications/minutely_precipitation.py b/src/accessiweather/notifications/minutely_precipitation.py new file mode 100644 index 000000000..aff275bfd --- /dev/null +++ b/src/accessiweather/notifications/minutely_precipitation.py @@ -0,0 +1,176 @@ +"""Pirate Weather minutely precipitation parsing and transition detection.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from ..models import MinutelyPrecipitationForecast, MinutelyPrecipitationPoint + +NO_TRANSITION_SIGNATURE = "__none__" + + +@dataclass(frozen=True) +class MinutelyPrecipitationTransition: + """A dry/wet transition detected in minutely precipitation data.""" + + transition_type: str # "starting" or "stopping" + minutes_until: int + precipitation_type: str | None = None + + @property + def event_type(self) -> str: + return ( + "minutely_precipitation_start" + if self.transition_type == "starting" + else "minutely_precipitation_stop" + ) + + @property + def title(self) -> str: + precipitation_label = precipitation_type_label(self.precipitation_type) + minute_label = "minute" if self.minutes_until == 1 else "minutes" + verb = "starting" if self.transition_type == "starting" else "stopping" + return f"{precipitation_label} {verb} in {self.minutes_until} {minute_label}" + + +def parse_pirate_weather_minutely_block( + payload: Mapping[str, Any] | None, +) -> MinutelyPrecipitationForecast | None: + """ + Parse a Pirate Weather minutely block or full response. + + Accepts either the full API response containing a ``minutely`` object or the + ``minutely`` object itself. + """ + if not payload: + return None + + minutely_payload = payload.get("minutely") if "minutely" in payload else payload + if not isinstance(minutely_payload, Mapping): + return None + + raw_points = minutely_payload.get("data") + if not isinstance(raw_points, list): + return None + + points: list[MinutelyPrecipitationPoint] = [] + for raw_point in raw_points: + if not isinstance(raw_point, Mapping): + continue + raw_time = raw_point.get("time") + if not isinstance(raw_time, (int, float)): + continue + points.append( + MinutelyPrecipitationPoint( + time=datetime.fromtimestamp(raw_time, tz=UTC), + precipitation_intensity=_coerce_float(raw_point.get("precipIntensity")), + precipitation_probability=_coerce_float(raw_point.get("precipProbability")), + precipitation_type=_normalize_precipitation_type(raw_point.get("precipType")), + ) + ) + + if not points: + return None + + summary = minutely_payload.get("summary") + icon = minutely_payload.get("icon") + return MinutelyPrecipitationForecast( + summary=str(summary) if isinstance(summary, str) else None, + icon=str(icon) if isinstance(icon, str) else None, + points=points, + ) + + +def detect_minutely_precipitation_transition( + forecast: MinutelyPrecipitationForecast | None, +) -> MinutelyPrecipitationTransition | None: + """Detect the first dry/wet transition in the next hour of minutely data.""" + if forecast is None or not forecast.points: + return None + + baseline_is_wet = is_wet(forecast.points[0]) + for idx, point in enumerate(forecast.points[1:], start=1): + if is_wet(point) == baseline_is_wet: + continue + if baseline_is_wet: + return MinutelyPrecipitationTransition( + transition_type="stopping", + minutes_until=idx, + precipitation_type=_first_precipitation_type(forecast.points[:idx]), + ) + return MinutelyPrecipitationTransition( + transition_type="starting", + minutes_until=idx, + precipitation_type=_first_precipitation_type(forecast.points[idx:]), + ) + + return None + + +def build_minutely_transition_signature( + forecast: MinutelyPrecipitationForecast | None, +) -> str | None: + """ + Return a stable signature for the current minutely transition state. + + ``None`` means the forecast was unavailable. ``NO_TRANSITION_SIGNATURE`` means + the forecast was available but no dry/wet transition was detected. + """ + if forecast is None or not forecast.points: + return None + + transition = detect_minutely_precipitation_transition(forecast) + if transition is None: + return NO_TRANSITION_SIGNATURE + + precip_type = transition.precipitation_type or "precipitation" + return f"{transition.transition_type}:{transition.minutes_until}:{precip_type}" + + +def is_wet(point: MinutelyPrecipitationPoint) -> bool: + """Return True when a minutely point indicates precipitation.""" + if point.precipitation_intensity is not None: + return point.precipitation_intensity > 0 + if point.precipitation_probability is not None: + return point.precipitation_probability > 0 + return False + + +def precipitation_type_label(precipitation_type: str | None) -> str: + """Return a user-facing precipitation label.""" + if precipitation_type == "sleet": + return "Sleet" + if precipitation_type == "snow": + return "Snow" + if precipitation_type == "hail": + return "Hail" + if precipitation_type == "freezing-rain": + return "Freezing rain" + if precipitation_type == "ice": + return "Ice" + if precipitation_type == "rain": + return "Rain" + return "Precipitation" + + +def _coerce_float(value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + return None + + +def _normalize_precipitation_type(value: Any) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip().lower() + return normalized or None + + +def _first_precipitation_type(points: list[MinutelyPrecipitationPoint]) -> str | None: + for point in points: + if is_wet(point) and point.precipitation_type: + return point.precipitation_type + return None diff --git a/src/accessiweather/notifications/notification_event_manager.py b/src/accessiweather/notifications/notification_event_manager.py index 24af70128..7f77707b3 100644 --- a/src/accessiweather/notifications/notification_event_manager.py +++ b/src/accessiweather/notifications/notification_event_manager.py @@ -4,8 +4,9 @@ This module provides state tracking and change detection for: - Area Forecast Discussion (AFD) updates (using NWS API issuanceTime) - Severe weather risk level changes +- Pirate Weather minutely precipitation start/stop transitions -Both notification types are opt-in (disabled by default) and can be +All notification types are opt-in (disabled by default) and can be enabled in Settings > Notifications. """ @@ -20,6 +21,10 @@ from typing import TYPE_CHECKING from ..runtime_state import RuntimeStateManager +from .minutely_precipitation import ( + build_minutely_transition_signature, + detect_minutely_precipitation_transition, +) if TYPE_CHECKING: from ..models import AppSettings, CurrentConditions, WeatherData @@ -165,7 +170,7 @@ def summarize_discussion_change(previous_text: str | None, current_text: str | N class NotificationEvent: """Represents a notification event to be sent.""" - event_type: str # 'discussion_update' or 'severe_risk' + event_type: str title: str message: str sound_event: str # Sound event key for the soundpack @@ -178,6 +183,7 @@ class NotificationState: last_discussion_issuance_time: datetime | None = None # NWS API issuanceTime last_discussion_text: str | None = None last_severe_risk: int | None = None + last_minutely_transition_signature: str | None = None last_check_time: datetime | None = None def to_dict(self) -> dict: @@ -190,6 +196,7 @@ def to_dict(self) -> dict: ), "last_discussion_text": self.last_discussion_text, "last_severe_risk": self.last_severe_risk, + "last_minutely_transition_signature": self.last_minutely_transition_signature, "last_check_time": self.last_check_time.isoformat() if self.last_check_time else None, } @@ -204,6 +211,7 @@ def from_dict(cls, data: dict) -> NotificationState: ), last_discussion_text=data.get("last_discussion_text"), last_severe_risk=data.get("last_severe_risk"), + last_minutely_transition_signature=data.get("last_minutely_transition_signature"), last_check_time=datetime.fromisoformat(last_check) if last_check else None, ) @@ -215,8 +223,9 @@ class NotificationEventManager: Tracks changes in: - Area Forecast Discussion (AFD) updates using NWS API issuanceTime - Severe weather risk levels (from Visual Crossing) + - Minutely precipitation start/stop transitions (from Pirate Weather) - Both notifications are opt-in (disabled by default). + All notifications are opt-in (disabled by default). """ def __init__( @@ -284,12 +293,17 @@ def _runtime_section_to_legacy_shape(section: dict) -> dict: """Convert unified runtime state to the legacy notification-state shape.""" discussion = section.get("discussion", {}) severe_risk = section.get("severe_risk", {}) + minutely_precipitation = section.get("minutely_precipitation", {}) return { "last_discussion_issuance_time": discussion.get("last_issuance_time"), "last_discussion_text": discussion.get("last_text"), "last_severe_risk": severe_risk.get("last_value"), + "last_minutely_transition_signature": minutely_precipitation.get( + "last_transition_signature" + ), "last_check_time": discussion.get("last_check_time") - or severe_risk.get("last_check_time"), + or severe_risk.get("last_check_time") + or minutely_precipitation.get("last_check_time"), } @staticmethod @@ -306,6 +320,10 @@ def _legacy_shape_to_runtime_section(data: dict) -> dict: "last_value": data.get("last_severe_risk"), "last_check_time": last_check_time, }, + "minutely_precipitation": { + "last_transition_signature": data.get("last_minutely_transition_signature"), + "last_check_time": last_check_time, + }, } def check_for_events( @@ -346,6 +364,18 @@ def check_for_events( if risk_event: events.append(risk_event) + if ( + settings.notify_minutely_precipitation_start + or settings.notify_minutely_precipitation_stop + ): + minutely_event = self._check_minutely_precipitation_transition( + weather_data.minutely_precipitation, + settings, + location_name, + ) + if minutely_event: + events.append(minutely_event) + # Update check time and save state self.state.last_check_time = datetime.now() self._save_state() @@ -506,6 +536,48 @@ def _check_severe_risk_change( self.state.last_severe_risk = severe_risk return None + def _check_minutely_precipitation_transition( + self, + minutely_precipitation, + settings: AppSettings, + location_name: str, + ) -> NotificationEvent | None: + """Check for a new dry/wet transition in Pirate Weather minutely guidance.""" + signature = build_minutely_transition_signature(minutely_precipitation) + if signature is None: + return None + + if self.state.last_minutely_transition_signature is None: + self.state.last_minutely_transition_signature = signature + logger.debug("First minutely precipitation state stored: %s", signature) + return None + + if signature == self.state.last_minutely_transition_signature: + return None + + self.state.last_minutely_transition_signature = signature + transition = detect_minutely_precipitation_transition(minutely_precipitation) + if transition is None: + return None + + if ( + transition.transition_type == "starting" + and not settings.notify_minutely_precipitation_start + ): + return None + if ( + transition.transition_type == "stopping" + and not settings.notify_minutely_precipitation_stop + ): + return None + + return NotificationEvent( + event_type=transition.event_type, + title=transition.title, + message=f"{transition.title} for {location_name}.", + sound_event="notify", + ) + def reset_state(self) -> None: """Reset all tracked state.""" self.state = NotificationState() diff --git a/src/accessiweather/ui/dialogs/settings_dialog.py b/src/accessiweather/ui/dialogs/settings_dialog.py index 4ad56da9c..0768a3227 100644 --- a/src/accessiweather/ui/dialogs/settings_dialog.py +++ b/src/accessiweather/ui/dialogs/settings_dialog.py @@ -568,6 +568,16 @@ def _create_notifications_tab(self): ) sizer.Add(self._controls["notify_severe_risk_change"], 0, wx.LEFT | wx.BOTTOM, 10) + self._controls["notify_minutely_precipitation_start"] = wx.CheckBox( + panel, label="Notify when precipitation is expected to start soon (Pirate Weather)" + ) + sizer.Add(self._controls["notify_minutely_precipitation_start"], 0, wx.LEFT, 10) + + self._controls["notify_minutely_precipitation_stop"] = wx.CheckBox( + panel, label="Notify when precipitation is expected to stop soon (Pirate Weather)" + ) + sizer.Add(self._controls["notify_minutely_precipitation_stop"], 0, wx.LEFT | wx.BOTTOM, 10) + # Rate Limiting Section sizer.Add( wx.StaticText(panel, label="Rate Limiting:"), @@ -1401,6 +1411,12 @@ def _load_settings(self): self._controls["notify_severe_risk_change"].SetValue( getattr(settings, "notify_severe_risk_change", False) ) + self._controls["notify_minutely_precipitation_start"].SetValue( + getattr(settings, "notify_minutely_precipitation_start", False) + ) + self._controls["notify_minutely_precipitation_stop"].SetValue( + getattr(settings, "notify_minutely_precipitation_stop", False) + ) # Audio tab self._controls["sound_enabled"].SetValue(getattr(settings, "sound_enabled", True)) @@ -1579,6 +1595,12 @@ def _save_settings(self) -> bool: # Event-based notifications "notify_discussion_update": self._controls["notify_discussion_update"].GetValue(), "notify_severe_risk_change": self._controls["notify_severe_risk_change"].GetValue(), + "notify_minutely_precipitation_start": self._controls[ + "notify_minutely_precipitation_start" + ].GetValue(), + "notify_minutely_precipitation_stop": self._controls[ + "notify_minutely_precipitation_stop" + ].GetValue(), # Audio "sound_enabled": self._controls["sound_enabled"].GetValue(), "sound_pack": self._sound_pack_ids[self._controls["sound_pack"].GetSelection()] @@ -1685,6 +1707,8 @@ def _setup_accessibility(self): "notify_unknown": "Unknown - Uncategorized alerts", "notify_discussion_update": "Notify when Area Forecast Discussion is updated (NWS US only)", "notify_severe_risk_change": "Notify when severe weather risk level changes (Visual Crossing only)", + "notify_minutely_precipitation_start": "Notify when precipitation is expected to start soon (Pirate Weather)", + "notify_minutely_precipitation_stop": "Notify when precipitation is expected to stop soon (Pirate Weather)", "global_cooldown": "Global cooldown (minutes)", "per_alert_cooldown": "Per-alert cooldown (minutes)", "freshness_window": "Alert freshness window (minutes)", diff --git a/src/accessiweather/ui/main_window_notification_events.py b/src/accessiweather/ui/main_window_notification_events.py index 101fbdb4f..62096e195 100644 --- a/src/accessiweather/ui/main_window_notification_events.py +++ b/src/accessiweather/ui/main_window_notification_events.py @@ -102,18 +102,26 @@ def process_notification_events(window: MainWindow, weather_data) -> None: Checks for: - Area Forecast Discussion (AFD) updates (NWS US only) - Severe weather risk level changes (Visual Crossing only) + - Minutely precipitation start/stop transitions (Pirate Weather) Both are opt-in notifications (disabled by default). """ try: settings = window.app.config_manager.get_settings() - if not settings.notify_discussion_update and not settings.notify_severe_risk_change: + if ( + not settings.notify_discussion_update + and not settings.notify_severe_risk_change + and not settings.notify_minutely_precipitation_start + and not settings.notify_minutely_precipitation_stop + ): logger.debug( - "[events] _process_notification_events: both discuss_update=%s and " - "severe_risk=%s disabled -- skipping", + "[events] _process_notification_events: discussion=%s severe_risk=%s " + "minutely_start=%s minutely_stop=%s disabled -- skipping", settings.notify_discussion_update, settings.notify_severe_risk_change, + settings.notify_minutely_precipitation_start, + settings.notify_minutely_precipitation_stop, ) return diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py index 60a8c4a4d..097a0eb42 100644 --- a/src/accessiweather/weather_client_base.py +++ b/src/accessiweather/weather_client_base.py @@ -1,6 +1,9 @@ """Core WeatherClient implementation with enrichment delegation.""" +from __future__ import annotations + import asyncio +import inspect import logging import os from collections.abc import Sequence @@ -32,11 +35,13 @@ Forecast, HourlyForecast, Location, + MinutelyPrecipitationForecast, SourceAttribution, SourceData, WeatherAlerts, WeatherData, ) +from .notifications.minutely_precipitation import parse_pirate_weather_minutely_block from .services import EnvironmentalDataClient from .utils.retry import APITimeoutError, retry_with_backoff from .visual_crossing_client import VisualCrossingApiError, VisualCrossingClient @@ -447,6 +452,8 @@ async def get_notification_event_data(self, location: Location) -> WeatherData: else: weather_data.alerts = WeatherAlerts(alerts=[]) + weather_data.minutely_precipitation = await self._get_pirate_weather_minutely(location) + loc_key = self._location_key(location) previous_alerts = self._previous_alerts.get(loc_key) _cancel_refs = await self._fetch_nws_cancel_references() @@ -461,6 +468,34 @@ async def get_notification_event_data(self, location: Location) -> WeatherData: return weather_data + async def _get_pirate_weather_minutely( + self, location: Location + ) -> MinutelyPrecipitationForecast | None: + """Fetch Pirate Weather minutely precipitation when a client is configured.""" + client = getattr(self, "pirate_weather_client", None) + if client is None: + return None + + for method_name in ("get_minutely_forecast", "get_forecast"): + method = getattr(client, method_name, None) + if not callable(method): + continue + try: + result = method(location) + if inspect.isawaitable(result): + result = await result + if isinstance(result, dict): + return parse_pirate_weather_minutely_block(result) + except TypeError: + logger.debug( + "Pirate Weather client method %s has an unsupported signature", method_name + ) + except Exception as exc: + logger.debug("Pirate Weather minutely fetch via %s failed: %s", method_name, exc) + return None + + return None + async def _fetch_weather_data_with_dedup( self, location: Location, force_refresh: bool, skip_notifications: bool = False ) -> WeatherData: diff --git a/tests/test_models.py b/tests/test_models.py index e5da94e94..0b14cbaaf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,6 +17,8 @@ HourlyForecast, HourlyForecastPeriod, Location, + MinutelyPrecipitationForecast, + MinutelyPrecipitationPoint, WeatherAlert, WeatherAlerts, WeatherData, @@ -331,6 +333,20 @@ def test_has_any_data(self): ) assert with_forecast.has_any_data() is True + with_minutely = WeatherData( + location=loc, + minutely_precipitation=MinutelyPrecipitationForecast( + points=[ + MinutelyPrecipitationPoint( + time=datetime.now(UTC), + precipitation_intensity=0.1, + precipitation_type="rain", + ) + ] + ), + ) + assert with_minutely.has_any_data() is True + class TestAppSettings: """Tests for AppSettings model.""" @@ -360,6 +376,18 @@ def test_custom_settings(self): assert settings.enable_alerts is False assert settings.data_source == "openmeteo" + def test_minutely_notification_settings_round_trip(self): + """Minutely precipitation notification settings should round-trip cleanly.""" + settings = AppSettings( + notify_minutely_precipitation_start=True, + notify_minutely_precipitation_stop=False, + ) + + restored = AppSettings.from_dict(settings.to_dict()) + + assert restored.notify_minutely_precipitation_start is True + assert restored.notify_minutely_precipitation_stop is False + def test_forecast_time_reference_validation(self): """Ensure invalid forecast_time_reference values fall back to location.""" settings = AppSettings() diff --git a/tests/test_notification_event_manager.py b/tests/test_notification_event_manager.py index 44935574f..d65ba471e 100644 --- a/tests/test_notification_event_manager.py +++ b/tests/test_notification_event_manager.py @@ -8,6 +8,11 @@ import pytest from accessiweather.models import AppSettings, CurrentConditions, WeatherData +from accessiweather.notifications.minutely_precipitation import ( + build_minutely_transition_signature, + detect_minutely_precipitation_transition, + parse_pirate_weather_minutely_block, +) from accessiweather.notifications.notification_event_manager import ( NotificationEvent, NotificationEventManager, @@ -50,6 +55,15 @@ def test_from_dict(self): assert state.last_discussion_issuance_time == datetime.fromisoformat(issuance_time_str) assert state.last_severe_risk == 75 + def test_minutely_signature_round_trip(self): + """Test minutely state serialization.""" + state = NotificationState(last_minutely_transition_signature="starting:12:rain") + + data = state.to_dict() + restored = NotificationState.from_dict(data) + + assert restored.last_minutely_transition_signature == "starting:12:rain" + class TestNotificationEventManager: """Tests for NotificationEventManager.""" @@ -96,6 +110,16 @@ def settings_both_enabled(self): settings.notify_severe_risk_change = True return settings + @pytest.fixture + def settings_with_minutely(self): + """Create settings with minutely precipitation notifications enabled.""" + settings = AppSettings() + settings.notify_discussion_update = False + settings.notify_severe_risk_change = False + settings.notify_minutely_precipitation_start = True + settings.notify_minutely_precipitation_stop = True + return settings + @pytest.fixture def settings_none_enabled(self): """Create settings with no event notifications enabled.""" @@ -418,7 +442,149 @@ def test_loaded_severe_risk_state_tracks_numeric_value_within_category(self, tmp assert len(threshold_events) == 1 assert threshold_events[0].event_type == "severe_risk" - assert "low to moderate" in threshold_events[0].message.lower() + + def test_parse_pirate_weather_minutely_block(self): + """Pirate Weather minutely payloads should parse into the shared forecast model.""" + forecast = parse_pirate_weather_minutely_block( + { + "minutely": { + "summary": "Rain starting in 12 minutes.", + "icon": "rain", + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0.02, + "precipProbability": 0.8, + "precipType": "rain", + }, + ], + } + } + ) + + assert forecast is not None + assert forecast.summary == "Rain starting in 12 minutes." + assert len(forecast.points) == 2 + assert forecast.points[1].precipitation_type == "rain" + + def test_detect_minutely_precipitation_start_transition(self): + """Dry-to-wet transitions should use the first wet minute and precip type.""" + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0}, + {"time": 1768917660, "precipIntensity": 0}, + {"time": 1768917720, "precipIntensity": 0.02, "precipType": "rain"}, + ] + } + ) + + transition = detect_minutely_precipitation_transition(forecast) + + assert transition is not None + assert transition.transition_type == "starting" + assert transition.minutes_until == 2 + assert transition.precipitation_type == "rain" + assert build_minutely_transition_signature(forecast) == "starting:2:rain" + + def test_detect_minutely_precipitation_stop_transition(self): + """Wet-to-dry transitions should announce when precipitation stops.""" + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0.03, "precipType": "snow"}, + {"time": 1768917660, "precipIntensity": 0.01, "precipType": "snow"}, + {"time": 1768917720, "precipIntensity": 0}, + ] + } + ) + + transition = detect_minutely_precipitation_transition(forecast) + + assert transition is not None + assert transition.transition_type == "stopping" + assert transition.minutes_until == 2 + assert transition.precipitation_type == "snow" + + def test_minutely_precipitation_transition_triggers_notification( + self, manager, settings_with_minutely + ): + """A changed minutely transition should generate a user-facing notification.""" + weather_data = MagicMock(spec=WeatherData) + weather_data.discussion = None + weather_data.discussion_issuance_time = None + weather_data.current = None + weather_data.minutely_precipitation = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0}, + {"time": 1768917660, "precipIntensity": 0.02, "precipType": "rain"}, + ] + } + ) + + first_events = manager.check_for_events(weather_data, settings_with_minutely, "Test City") + second_events = manager.check_for_events(weather_data, settings_with_minutely, "Test City") + assert first_events == [] + assert second_events == [] + + weather_data.minutely_precipitation = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0}, + {"time": 1768917660, "precipIntensity": 0}, + {"time": 1768917720, "precipIntensity": 0.02, "precipType": "rain"}, + ] + } + ) + + events = manager.check_for_events(weather_data, settings_with_minutely, "Test City") + + assert len(events) == 1 + assert events[0].event_type == "minutely_precipitation_start" + assert events[0].title == "Rain starting in 2 minutes" + assert events[0].message == "Rain starting in 2 minutes for Test City." + assert manager.state.last_minutely_transition_signature == "starting:2:rain" + + def test_minutely_precipitation_stop_can_be_disabled(self, manager): + """Disabled stop notifications should still update state without notifying.""" + settings = AppSettings( + notify_discussion_update=False, + notify_severe_risk_change=False, + notify_minutely_precipitation_start=True, + notify_minutely_precipitation_stop=False, + ) + weather_data = MagicMock(spec=WeatherData) + weather_data.discussion = None + weather_data.discussion_issuance_time = None + weather_data.current = None + weather_data.minutely_precipitation = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0.04, "precipType": "rain"}, + {"time": 1768917660, "precipIntensity": 0}, + ] + } + ) + + first_events = manager.check_for_events(weather_data, settings, "Test City") + assert first_events == [] + + weather_data.minutely_precipitation = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0.04, "precipType": "rain"}, + {"time": 1768917660, "precipIntensity": 0.03, "precipType": "rain"}, + {"time": 1768917720, "precipIntensity": 0}, + ] + } + ) + + events = manager.check_for_events(weather_data, settings, "Test City") + + assert events == [] + assert manager.state.last_minutely_transition_signature == "stopping:2:rain" def test_reset_state(self, manager): """Test state reset.""" @@ -431,6 +597,7 @@ def test_reset_state(self, manager): assert manager.state.last_discussion_issuance_time is None assert manager.state.last_severe_risk is None + assert manager.state.last_minutely_transition_signature is None class TestNotificationEvent: