From 0461204a1a22782d5d32e50a30c11233362cf503 Mon Sep 17 00:00:00 2001 From: Orinks Date: Fri, 3 Apr 2026 16:11:20 +0000 Subject: [PATCH] feat(notifications): probability-based precipitation notification (#566) --- src/accessiweather/models/config.py | 12 + .../notifications/minutely_precipitation.py | 96 +++++++ .../notification_event_manager.py | 50 ++++ .../ui/dialogs/settings_tabs/notifications.py | 37 +++ .../test_minutely_precipitation_likelihood.py | 241 ++++++++++++++++++ tests/test_notification_event_manager.py | 144 +++++++++++ 6 files changed, 580 insertions(+) create mode 100644 tests/test_minutely_precipitation_likelihood.py diff --git a/src/accessiweather/models/config.py b/src/accessiweather/models/config.py index 2736f8f9..ca1a5f57 100644 --- a/src/accessiweather/models/config.py +++ b/src/accessiweather/models/config.py @@ -42,6 +42,8 @@ "notify_severe_risk_change", "notify_minutely_precipitation_start", "notify_minutely_precipitation_stop", + "notify_precipitation_likelihood", + "precipitation_likelihood_threshold", # GitHub settings "github_backend_url", "github_app_id", @@ -123,6 +125,8 @@ class AppSettings: notify_severe_risk_change: bool = False notify_minutely_precipitation_start: bool = True notify_minutely_precipitation_stop: bool = True + notify_precipitation_likelihood: bool = True + precipitation_likelihood_threshold: float = 0.5 github_backend_url: str = "" github_app_id: str = "" github_app_private_key: str = "" @@ -425,6 +429,8 @@ def to_dict(self) -> dict: "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, + "notify_precipitation_likelihood": self.notify_precipitation_likelihood, + "precipitation_likelihood_threshold": self.precipitation_likelihood_threshold, "github_backend_url": self.github_backend_url, "alert_radius_type": self.alert_radius_type, "alert_notifications_enabled": self.alert_notifications_enabled, @@ -511,6 +517,12 @@ def from_dict(cls, data: dict) -> AppSettings: notify_minutely_precipitation_stop=cls._as_bool( data.get("notify_minutely_precipitation_stop"), True ), + notify_precipitation_likelihood=cls._as_bool( + data.get("notify_precipitation_likelihood"), True + ), + precipitation_likelihood_threshold=float( + data.get("precipitation_likelihood_threshold", 0.5) + ), 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/notifications/minutely_precipitation.py b/src/accessiweather/notifications/minutely_precipitation.py index aff275bf..5056a836 100644 --- a/src/accessiweather/notifications/minutely_precipitation.py +++ b/src/accessiweather/notifications/minutely_precipitation.py @@ -10,6 +10,23 @@ from ..models import MinutelyPrecipitationForecast, MinutelyPrecipitationPoint NO_TRANSITION_SIGNATURE = "__none__" +NO_LIKELIHOOD_SIGNATURE = "__no_likelihood__" + +# Probability band thresholds (upper-exclusive boundaries, from highest to lowest) +_PROBABILITY_BANDS: list[tuple[float, str]] = [ + (0.9, "90+"), + (0.7, "70-90"), + (0.5, "50-70"), +] + + +def _probability_band(prob: float) -> str: + """Return the probability band label for a given probability value.""" + if prob >= 0.9: + return "90%+" + if prob >= 0.7: + return "70-90%" + return "50-70%" @dataclass(frozen=True) @@ -36,6 +53,25 @@ def title(self) -> str: return f"{precipitation_label} {verb} in {self.minutes_until} {minute_label}" +@dataclass(frozen=True) +class MinutelyPrecipitationLikelihood: + """Probability-based precipitation likelihood detected in minutely data.""" + + max_probability: float + precipitation_type: str | None = None + probability_band: str = "" + + @property + def event_type(self) -> str: + return "minutely_precipitation_likelihood" + + @property + def title(self) -> str: + precipitation_label = precipitation_type_label(self.precipitation_type) + pct = int(self.max_probability * 100) + return f"{precipitation_label} likely in the next hour ({pct}% chance)" + + def parse_pirate_weather_minutely_block( payload: Mapping[str, Any] | None, ) -> MinutelyPrecipitationForecast | None: @@ -130,6 +166,66 @@ def build_minutely_transition_signature( return f"{transition.transition_type}:{transition.minutes_until}:{precip_type}" +def detect_minutely_precipitation_likelihood( + forecast: MinutelyPrecipitationForecast | None, + threshold: float = 0.5, +) -> MinutelyPrecipitationLikelihood | None: + """ + Detect if precipitation probability exceeds *threshold* in the next hour. + + Returns ``None`` when: + - *forecast* is ``None`` or has no points. + - The first data point is already wet (current conditions are wet). + - No point has ``precipitation_probability`` above *threshold*. + """ + if forecast is None or not forecast.points: + return None + + # If currently wet, this notification is not applicable + if is_wet(forecast.points[0]): + return None + + max_prob: float = 0.0 + max_precip_type: str | None = None + + for point in forecast.points: + prob = point.precipitation_probability + if prob is not None and prob > max_prob: + max_prob = prob + max_precip_type = point.precipitation_type + + if max_prob < threshold: + return None + + return MinutelyPrecipitationLikelihood( + max_probability=max_prob, + precipitation_type=max_precip_type, + probability_band=_probability_band(max_prob), + ) + + +def build_minutely_likelihood_signature( + forecast: MinutelyPrecipitationForecast | None, + threshold: float = 0.5, +) -> str | None: + """ + Return a stable deduplication signature for the likelihood state. + + Returns ``None`` when the forecast is unavailable, + ``NO_LIKELIHOOD_SIGNATURE`` when no likelihood is detected, or + ``"likelihood:{band}:{precip_type}"`` otherwise. + """ + if forecast is None or not forecast.points: + return None + + likelihood = detect_minutely_precipitation_likelihood(forecast, threshold) + if likelihood is None: + return NO_LIKELIHOOD_SIGNATURE + + precip_type = likelihood.precipitation_type or "precipitation" + return f"likelihood:{likelihood.probability_band}:{precip_type}" + + def is_wet(point: MinutelyPrecipitationPoint) -> bool: """Return True when a minutely point indicates precipitation.""" if point.precipitation_intensity is not None: diff --git a/src/accessiweather/notifications/notification_event_manager.py b/src/accessiweather/notifications/notification_event_manager.py index 7f77707b..18b646cf 100644 --- a/src/accessiweather/notifications/notification_event_manager.py +++ b/src/accessiweather/notifications/notification_event_manager.py @@ -22,7 +22,9 @@ from ..runtime_state import RuntimeStateManager from .minutely_precipitation import ( + build_minutely_likelihood_signature, build_minutely_transition_signature, + detect_minutely_precipitation_likelihood, detect_minutely_precipitation_transition, ) @@ -184,6 +186,7 @@ class NotificationState: last_discussion_text: str | None = None last_severe_risk: int | None = None last_minutely_transition_signature: str | None = None + last_minutely_likelihood_signature: str | None = None last_check_time: datetime | None = None def to_dict(self) -> dict: @@ -197,6 +200,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_minutely_likelihood_signature": self.last_minutely_likelihood_signature, "last_check_time": self.last_check_time.isoformat() if self.last_check_time else None, } @@ -212,6 +216,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_minutely_likelihood_signature=data.get("last_minutely_likelihood_signature"), last_check_time=datetime.fromisoformat(last_check) if last_check else None, ) @@ -301,6 +306,9 @@ def _runtime_section_to_legacy_shape(section: dict) -> dict: "last_minutely_transition_signature": minutely_precipitation.get( "last_transition_signature" ), + "last_minutely_likelihood_signature": minutely_precipitation.get( + "last_likelihood_signature" + ), "last_check_time": discussion.get("last_check_time") or severe_risk.get("last_check_time") or minutely_precipitation.get("last_check_time"), @@ -322,6 +330,7 @@ def _legacy_shape_to_runtime_section(data: dict) -> dict: }, "minutely_precipitation": { "last_transition_signature": data.get("last_minutely_transition_signature"), + "last_likelihood_signature": data.get("last_minutely_likelihood_signature"), "last_check_time": last_check_time, }, } @@ -376,6 +385,15 @@ def check_for_events( if minutely_event: events.append(minutely_event) + if getattr(settings, "notify_precipitation_likelihood", True): + likelihood_event = self._check_minutely_precipitation_likelihood( + weather_data.minutely_precipitation, + settings, + location_name, + ) + if likelihood_event: + events.append(likelihood_event) + # Update check time and save state self.state.last_check_time = datetime.now() self._save_state() @@ -578,6 +596,38 @@ def _check_minutely_precipitation_transition( sound_event="notify", ) + def _check_minutely_precipitation_likelihood( + self, + minutely_precipitation, + settings: AppSettings, + location_name: str, + ) -> NotificationEvent | None: + """Check for probability-based precipitation likelihood in minutely data.""" + threshold = getattr(settings, "precipitation_likelihood_threshold", 0.5) + signature = build_minutely_likelihood_signature(minutely_precipitation, threshold) + if signature is None: + return None + + if self.state.last_minutely_likelihood_signature is None: + self.state.last_minutely_likelihood_signature = signature + logger.debug("First minutely likelihood state stored: %s", signature) + return None + + if signature == self.state.last_minutely_likelihood_signature: + return None + + self.state.last_minutely_likelihood_signature = signature + likelihood = detect_minutely_precipitation_likelihood(minutely_precipitation, threshold) + if likelihood is None: + return None + + return NotificationEvent( + event_type=likelihood.event_type, + title=likelihood.title, + message=f"{likelihood.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_tabs/notifications.py b/src/accessiweather/ui/dialogs/settings_tabs/notifications.py index 46834f3b..05db2f30 100644 --- a/src/accessiweather/ui/dialogs/settings_tabs/notifications.py +++ b/src/accessiweather/ui/dialogs/settings_tabs/notifications.py @@ -11,6 +11,10 @@ _RADIUS_TYPE_VALUES = ["county", "point", "zone", "state"] _RADIUS_TYPE_MAP = {"county": 0, "point": 1, "zone": 2, "state": 3} +_LIKELIHOOD_THRESHOLD_LABELS = ["50%", "60%", "70%", "80%"] +_LIKELIHOOD_THRESHOLD_VALUES = [0.5, 0.6, 0.7, 0.8] +_LIKELIHOOD_THRESHOLD_MAP = {0.5: 0, 0.6: 1, 0.7: 2, 0.8: 3} + class NotificationsTab: """Notifications tab: alert settings, severity levels, event notifications, rate limiting.""" @@ -118,6 +122,24 @@ def create(self): ) sizer.Add(controls["notify_minutely_precipitation_stop"], 0, wx.LEFT | wx.BOTTOM, 10) + controls["notify_precipitation_likelihood"] = wx.CheckBox( + panel, label="Notify when precipitation is likely (probability-based)" + ) + sizer.Add(controls["notify_precipitation_likelihood"], 0, wx.LEFT, 10) + + row_threshold = wx.BoxSizer(wx.HORIZONTAL) + row_threshold.Add( + wx.StaticText(panel, label="Precipitation likelihood threshold:"), + 0, + wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, + 10, + ) + controls["precipitation_likelihood_threshold"] = wx.Choice( + panel, choices=_LIKELIHOOD_THRESHOLD_LABELS + ) + row_threshold.Add(controls["precipitation_likelihood_threshold"], 0) + sizer.Add(row_threshold, 0, wx.LEFT | wx.BOTTOM, 10) + # Hidden alert timing controls (values managed via Advanced dialog) controls["global_cooldown"] = wx.SpinCtrl(panel, min=0, max=60, initial=5) controls["global_cooldown"].Hide() @@ -188,6 +210,13 @@ def load(self, settings): controls["notify_minutely_precipitation_stop"].SetValue( getattr(settings, "notify_minutely_precipitation_stop", False) ) + controls["notify_precipitation_likelihood"].SetValue( + getattr(settings, "notify_precipitation_likelihood", True) + ) + threshold_val = getattr(settings, "precipitation_likelihood_threshold", 0.5) + controls["precipitation_likelihood_threshold"].SetSelection( + _LIKELIHOOD_THRESHOLD_MAP.get(threshold_val, 0) + ) def save(self) -> dict: """Return Notifications tab settings as a dict.""" @@ -213,6 +242,12 @@ def save(self) -> dict: "notify_minutely_precipitation_stop": controls[ "notify_minutely_precipitation_stop" ].GetValue(), + "notify_precipitation_likelihood": controls[ + "notify_precipitation_likelihood" + ].GetValue(), + "precipitation_likelihood_threshold": _LIKELIHOOD_THRESHOLD_VALUES[ + controls["precipitation_likelihood_threshold"].GetSelection() + ], } def setup_accessibility(self): @@ -231,6 +266,8 @@ def setup_accessibility(self): "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)", + "notify_precipitation_likelihood": "Notify when precipitation is likely (probability-based)", + "precipitation_likelihood_threshold": "Precipitation likelihood threshold", "global_cooldown": "Minimum time between any alert notifications (minutes)", "per_alert_cooldown": "Re-notify for same alert after (minutes)", "freshness_window": "Only notify for alerts issued within (minutes)", diff --git a/tests/test_minutely_precipitation_likelihood.py b/tests/test_minutely_precipitation_likelihood.py new file mode 100644 index 00000000..f2222acb --- /dev/null +++ b/tests/test_minutely_precipitation_likelihood.py @@ -0,0 +1,241 @@ +"""Tests for probability-based precipitation likelihood detection.""" + +from __future__ import annotations + +from accessiweather.notifications.minutely_precipitation import ( + NO_LIKELIHOOD_SIGNATURE, + MinutelyPrecipitationLikelihood, + _probability_band, + build_minutely_likelihood_signature, + detect_minutely_precipitation_likelihood, + parse_pirate_weather_minutely_block, +) + + +class TestProbabilityBand: + """Tests for the _probability_band helper.""" + + def test_band_50_70(self): + assert _probability_band(0.5) == "50-70%" + assert _probability_band(0.6) == "50-70%" + assert _probability_band(0.69) == "50-70%" + + def test_band_70_90(self): + assert _probability_band(0.7) == "70-90%" + assert _probability_band(0.8) == "70-90%" + assert _probability_band(0.89) == "70-90%" + + def test_band_90_plus(self): + assert _probability_band(0.9) == "90%+" + assert _probability_band(0.95) == "90%+" + assert _probability_band(1.0) == "90%+" + + +class TestDetectMinutelyPrecipitationLikelihood: + """Tests for detect_minutely_precipitation_likelihood.""" + + def test_returns_none_for_none_forecast(self): + assert detect_minutely_precipitation_likelihood(None) is None + + def test_returns_none_when_currently_wet(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0.05, "precipType": "rain"}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.8, + "precipType": "rain", + }, + ] + } + ) + assert detect_minutely_precipitation_likelihood(forecast) is None + + def test_returns_none_when_all_below_threshold(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + {"time": 1768917660, "precipIntensity": 0, "precipProbability": 0.3}, + {"time": 1768917720, "precipIntensity": 0, "precipProbability": 0.4}, + ] + } + ) + assert detect_minutely_precipitation_likelihood(forecast, threshold=0.5) is None + + def test_detects_likelihood_above_threshold(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.7, + "precipType": "rain", + }, + { + "time": 1768917720, + "precipIntensity": 0, + "precipProbability": 0.5, + "precipType": "rain", + }, + ] + } + ) + result = detect_minutely_precipitation_likelihood(forecast, threshold=0.5) + assert result is not None + assert result.max_probability == 0.7 + assert result.precipitation_type == "rain" + assert result.probability_band == "70-90%" + + def test_uses_max_probability(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.6, + "precipType": "rain", + }, + { + "time": 1768917720, + "precipIntensity": 0, + "precipProbability": 0.95, + "precipType": "snow", + }, + ] + } + ) + result = detect_minutely_precipitation_likelihood(forecast, threshold=0.5) + assert result is not None + assert result.max_probability == 0.95 + assert result.precipitation_type == "snow" + assert result.probability_band == "90%+" + + def test_custom_threshold(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.65, + "precipType": "rain", + }, + ] + } + ) + # With default threshold 0.5, should detect + assert detect_minutely_precipitation_likelihood(forecast, threshold=0.5) is not None + # With higher threshold 0.7, should not detect + assert detect_minutely_precipitation_likelihood(forecast, threshold=0.7) is None + + +class TestMinutelyPrecipitationLikelihood: + """Tests for the MinutelyPrecipitationLikelihood dataclass.""" + + def test_event_type(self): + likelihood = MinutelyPrecipitationLikelihood( + max_probability=0.7, precipitation_type="rain", probability_band="70-90%" + ) + assert likelihood.event_type == "minutely_precipitation_likelihood" + + def test_title_with_rain(self): + likelihood = MinutelyPrecipitationLikelihood( + max_probability=0.7, precipitation_type="rain", probability_band="70-90%" + ) + assert likelihood.title == "Rain likely in the next hour (70% chance)" + + def test_title_with_snow(self): + likelihood = MinutelyPrecipitationLikelihood( + max_probability=0.95, precipitation_type="snow", probability_band="90%+" + ) + assert likelihood.title == "Snow likely in the next hour (95% chance)" + + def test_title_without_type(self): + likelihood = MinutelyPrecipitationLikelihood( + max_probability=0.6, precipitation_type=None, probability_band="50-70%" + ) + assert likelihood.title == "Precipitation likely in the next hour (60% chance)" + + +class TestBuildMinutelyLikelihoodSignature: + """Tests for build_minutely_likelihood_signature.""" + + def test_returns_none_for_none_forecast(self): + assert build_minutely_likelihood_signature(None) is None + + def test_returns_no_likelihood_when_below_threshold(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + {"time": 1768917660, "precipIntensity": 0, "precipProbability": 0.3}, + ] + } + ) + assert build_minutely_likelihood_signature(forecast) == NO_LIKELIHOOD_SIGNATURE + + def test_returns_signature_with_band_and_type(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.8, + "precipType": "rain", + }, + ] + } + ) + sig = build_minutely_likelihood_signature(forecast) + assert sig == "likelihood:70-90%:rain" + + def test_returns_signature_with_default_type(self): + forecast = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + {"time": 1768917660, "precipIntensity": 0, "precipProbability": 0.6}, + ] + } + ) + sig = build_minutely_likelihood_signature(forecast) + assert sig == "likelihood:50-70%:precipitation" + + def test_different_bands_produce_different_signatures(self): + data_70 = { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.75, + "precipType": "rain", + }, + ] + } + data_95 = { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.95, + "precipType": "rain", + }, + ] + } + sig1 = build_minutely_likelihood_signature(parse_pirate_weather_minutely_block(data_70)) + sig2 = build_minutely_likelihood_signature(parse_pirate_weather_minutely_block(data_95)) + assert sig1 != sig2 + assert sig1 == "likelihood:70-90%:rain" + assert sig2 == "likelihood:90%+:rain" diff --git a/tests/test_notification_event_manager.py b/tests/test_notification_event_manager.py index fd8e5462..dbb15272 100644 --- a/tests/test_notification_event_manager.py +++ b/tests/test_notification_event_manager.py @@ -613,6 +613,150 @@ def test_reset_state(self, manager): assert manager.state.last_minutely_transition_signature is None +class TestMinutelyPrecipitationLikelihoodNotification: + """Tests for precipitation likelihood notification integration.""" + + @pytest.fixture + def manager(self): + return NotificationEventManager(state_file=None) + + @pytest.fixture + def settings_with_likelihood(self): + settings = AppSettings() + settings.notify_discussion_update = False + settings.notify_severe_risk_change = False + settings.notify_minutely_precipitation_start = False + settings.notify_minutely_precipitation_stop = False + settings.notify_precipitation_likelihood = True + settings.precipitation_likelihood_threshold = 0.5 + return settings + + def test_first_check_stores_state_no_notification(self, manager, settings_with_likelihood): + """First likelihood check should store state but not notify.""" + 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, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.7, + "precipType": "rain", + }, + ] + } + ) + events = manager.check_for_events(weather_data, settings_with_likelihood, "Test City") + assert events == [] + assert manager.state.last_minutely_likelihood_signature is not None + + def test_same_signature_no_notification(self, manager, settings_with_likelihood): + """Same likelihood signature should not re-notify.""" + 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, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.7, + "precipType": "rain", + }, + ] + } + ) + manager.check_for_events(weather_data, settings_with_likelihood, "Test City") + events = manager.check_for_events(weather_data, settings_with_likelihood, "Test City") + assert events == [] + + def test_band_change_triggers_notification(self, manager, settings_with_likelihood): + """A change in probability band should trigger a 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, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.6, + "precipType": "rain", + }, + ] + } + ) + # First check — stores state + manager.check_for_events(weather_data, settings_with_likelihood, "Test City") + + # Change to a higher band + weather_data.minutely_precipitation = parse_pirate_weather_minutely_block( + { + "data": [ + {"time": 1768917600, "precipIntensity": 0, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.95, + "precipType": "rain", + }, + ] + } + ) + events = manager.check_for_events(weather_data, settings_with_likelihood, "Test City") + assert len(events) == 1 + assert events[0].event_type == "minutely_precipitation_likelihood" + assert "95% chance" in events[0].title + assert "Test City" in events[0].message + + def test_disabled_setting_no_notification(self, manager): + """Disabled likelihood setting should not produce notifications.""" + settings = AppSettings() + settings.notify_discussion_update = False + settings.notify_severe_risk_change = False + settings.notify_minutely_precipitation_start = False + settings.notify_minutely_precipitation_stop = False + settings.notify_precipitation_likelihood = 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, "precipProbability": 0}, + { + "time": 1768917660, + "precipIntensity": 0, + "precipProbability": 0.9, + "precipType": "rain", + }, + ] + } + ) + manager.check_for_events(weather_data, settings, "Test City") + events = manager.check_for_events(weather_data, settings, "Test City") + assert events == [] + + def test_likelihood_signature_round_trip(self): + """Test likelihood signature serialization in NotificationState.""" + state = NotificationState(last_minutely_likelihood_signature="likelihood:70-90%:rain") + data = state.to_dict() + restored = NotificationState.from_dict(data) + assert restored.last_minutely_likelihood_signature == "likelihood:70-90%:rain" + + class TestNotificationEvent: """Tests for NotificationEvent dataclass."""