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
12 changes: 12 additions & 0 deletions src/accessiweather/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
96 changes: 96 additions & 0 deletions src/accessiweather/notifications/minutely_precipitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions src/accessiweather/notifications/notification_event_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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:
Expand All @@ -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,
}

Expand All @@ -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,
)

Expand Down Expand Up @@ -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"),
Expand All @@ -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,
},
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions src/accessiweather/ui/dialogs/settings_tabs/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."""
Expand All @@ -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):
Expand All @@ -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)",
Expand Down
Loading
Loading