Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/accessiweather/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
HourlyForecastPeriod,
HourlyUVIndex,
Location,
MinutelyPrecipitationForecast,
MinutelyPrecipitationPoint,
SourceAttribution,
SourceData,
TrendInsight,
Expand All @@ -40,6 +42,8 @@
"HourlyForecast",
"HourlyAirQuality",
"HourlyUVIndex",
"MinutelyPrecipitationPoint",
"MinutelyPrecipitationForecast",
"TrendInsight",
"EnvironmentalConditions",
"AviationData",
Expand Down
12 changes: 12 additions & 0 deletions src/accessiweather/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
25 changes: 25 additions & 0 deletions src/accessiweather/models/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions src/accessiweather/notifications/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
176 changes: 176 additions & 0 deletions src/accessiweather/notifications/minutely_precipitation.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading