diff --git a/.dockerignore b/.dockerignore index 6584341..f112e3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,7 +28,12 @@ ENV/ # OS .DS_Store +**/.DS_Store Thumbs.db +# macOS AppleDouble sidecar files (created when copying to SMB/exFAT shares). +# Keep them out of the build context so the plugin loader doesn't try to import them. +._* +**/._* # Project specific *.db diff --git a/config.ini.example b/config.ini.example index 8d6a1e9..58b48fc 100644 --- a/config.ini.example +++ b/config.ini.example @@ -758,6 +758,24 @@ enabled = true # default_lat = 48.08 # default_lon = -121.97 +[Rain_Command] +# Minute-level rain nowcast ("rain starting in ~20min") via Open-Meteo's +# 15-minutely precipitation. Worldwide, no API key. Keywords: rain, nowcast. +# Usage: rain [city|zipcode|lat,lon] (no location -> companion, then bot location) +enabled = true +# How far ahead to look, in minutes (default: 120) +# window_minutes = 120 +# Precipitation amount (mm per 15-min bucket) at/above which it counts as raining. +# Lower = more sensitive to light drizzle; raise to ignore trace amounts. (default: 0.1) +# precip_threshold_mm = 0.1 +# Look up US ZIP -> "City, ST" via Zippopotam.us (free, no key). OpenStreetMap +# often lacks the city for a ZIP centroid, so this gives nicer labels. Set false +# to skip the external lookup and reverse-geocode instead. (default: true) +# zip_city_lookup = true +# Optional: default coordinates when user does not specify a location +# default_lat = 47.6062 +# default_lon = -122.3321 + [Channels_List] # Common hashtag channels for the region # Format: channel_name = description @@ -1717,6 +1735,35 @@ alerts_channel = #weather # Default: 600000 (10 minutes) poll_weather_alerts_interval = 600000 +# --- Proactive rain nowcast ("rain incoming" push) --- +# Automatically posts a heads-up to a channel when precipitation is about to +# start at the bot's position (my_position_lat/lon above), using Open-Meteo's +# 15-minutely forecast — the same engine as the `rain`/`nowcast` command. +# Fires once per rain episode (deduped), re-arms after it clears. +# Opt-in: set true to enable. Default: false +rain_nowcast_enabled = false + +# Channel for the heads-up. Defaults to weather_channel if unset. +# rain_channel = #weather + +# How often to check for incoming rain (milliseconds). Default: 900000 (15 min) +poll_rain_nowcast_interval = 900000 + +# Only announce when rain is expected to start within this many minutes. +# Default: 60 +rain_nowcast_lead_minutes = 60 + +# Minimum minutes between pushes (cooldown backstop against forecast flapping). +# Default: 30 +rain_nowcast_renotify_minutes = 30 + +# Also announce when rain is about to STOP (not just start). Default: true +# rain_nowcast_announce_ending = true + +# Precipitation amount (mm per 15-min bucket) that counts as rain. Lower = more +# sensitive to light drizzle; raise to ignore trace amounts. Default: 0.1 +# rain_nowcast_threshold_mm = 0.1 + # Thunder/storm data collection interval (milliseconds) # How often to aggregate thunder data for evaluation # Default: 600000 (10 minutes) diff --git a/docs/command-reference.md b/docs/command-reference.md index 295f70e..7f87b7d 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -242,6 +242,40 @@ aqi help --- +### `rain ` + +Minute-level rain nowcast — tells you when precipitation is about to **start** or **stop** in the next couple hours, using Open-Meteo's 15-minutely precipitation forecast. Works worldwide with no API key. + +**Aliases:** `nowcast` + +**Usage:** +``` +rain [city|zipcode|lat,lon] +nowcast [city|zipcode|lat,lon] +``` + +**Examples:** +``` +rain +rain seattle +rain 98101 +rain 47.6,-122.3 +``` + +**Response:** A single line describing the upcoming precipitation, for example: +- `🌧️ Rain starting in ~25min for Seattle (~45min)` — dry now, rain expected (with rough duration) +- `🌧️ Rain easing in ~20min for Seattle` — raining now, clearing soon +- `🌧️ Heavy rain steady for 2h+ in Seattle` — raining now, no break in the window +- `☀️ No rain expected in next 2h for Seattle` — dry through the window + +When no location is given, uses the sender's companion location if known, then the bot's configured location. + +**Configuration:** `[Rain_Command]` — `enabled`, `window_minutes` (how far ahead to look, default 120), `precip_threshold_mm` (sensitivity, default 0.1), and optional `default_lat`/`default_lon`. Temperature/precipitation source units are shared via `[Weather]` (`weather_model` is honored). + +**Note:** Falls back to hourly precipitation when a weather model doesn't provide 15-minute data for the area. + +--- + ### `airplanes [location] [options]` / `overhead [lat,lon]` Get aircraft tracking information using ADS-B data from airplanes.live or compatible APIs. diff --git a/docs/weather-service.md b/docs/weather-service.md index 71700a0..0115498 100644 --- a/docs/weather-service.md +++ b/docs/weather-service.md @@ -48,6 +48,31 @@ alerts_channel = #weather # Channel for weather alerts poll_weather_alerts_interval = 600000 # Check for alerts every 10 minutes (milliseconds) ``` +### Rain Nowcast (Proactive) + +Automatically posts a heads-up when rain is about to start at your position: + +```ini +rain_nowcast_enabled = true # Auto-announce incoming rain (opt-in; default: false) +# rain_channel = #weather # Defaults to weather_channel +poll_rain_nowcast_interval = 900000 # Check every 15 minutes (milliseconds) +rain_nowcast_lead_minutes = 60 # Only announce if rain starts within 60 min +rain_nowcast_renotify_minutes = 30 # Cooldown between pushes +# rain_nowcast_announce_ending = true # Also announce when rain is about to stop +# rain_nowcast_threshold_mm = 0.1 # Sensitivity (mm per 15-min bucket) +``` + +Posts both a **starting** heads-up (rain incoming) and, by default, an **ending** +one (rain about to stop): + +``` +🌧️ Heads up — Rain starting in ~25min near Nashville, TN +🌧️ Heads up — Rain ending in ~20min near Nashville, TN +``` + +Each fires once per rain episode. Set `rain_nowcast_announce_ending = false` to +only announce incoming rain. + ### Lightning Detection (Optional) Requires `paho-mqtt` library. @@ -86,6 +111,25 @@ Sends forecast to `weather_channel` at configured time: - Sunrise: `weather_alarm = sunrise` - Sunset: `weather_alarm = sunset` +### Rain Nowcast (Proactive) + +Watches your position and posts a heads-up to `rain_channel` (default: `weather_channel`) when precipitation is about to start, using Open-Meteo's 15-minutely forecast — the same engine as the [`rain`/`nowcast` command](command-reference.md#rain-location). + +**Example Output:** +``` +🌧️ Heads up — Rain starting in ~25min near Seattle +🌨️ Heads up — Snow starting in ~40min (steady) near Seattle +``` + +**How It Works:** +1. Polls every `poll_rain_nowcast_interval` (default 15 min) +2. Announces when rain is expected within `rain_nowcast_lead_minutes` (default 60) +3. Fires **once per rain episode** — re-arms only after the forecast clears +4. A `rain_nowcast_renotify_minutes` cooldown (default 30) absorbs forecast flapping +5. `(steady)` marks prolonged rain (continues past the look-ahead window) + +Works worldwide (no API key). Set `rain_nowcast_enabled = false` to disable. + ### Weather Alerts (US Only) Monitors NOAA weather alerts and posts new alerts to `alerts_channel`: diff --git a/modules/commands/rain_command.py b/modules/commands/rain_command.py new file mode 100644 index 0000000..3a76fc9 --- /dev/null +++ b/modules/commands/rain_command.py @@ -0,0 +1,810 @@ +#!/usr/bin/env python3 +""" +Rain nowcast command - minute-level "rain starting/stopping in ~N min" using +Open-Meteo's 15-minutely precipitation forecast. Works worldwide, no API key. +""" + +import asyncio +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Optional + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from ..models import MeshMessage +from ..utils import geocode_city_sync, geocode_zipcode_sync, normalize_us_state +from .base_command import BaseCommand + +# WMO weather code -> precipitation "bucket". Buckets map to an emoji and a +# translatable label (commands.rain.precip_types.). Codes not listed +# here are non-precipitating and never trigger a nowcast. +_PRECIP_BUCKETS: dict[int, str] = { + 51: "drizzle", 53: "drizzle", 55: "drizzle", + 56: "freezing", 57: "freezing", + 61: "rain", 63: "rain", 65: "heavy_rain", + 66: "freezing", 67: "freezing", + 71: "snow", 73: "snow", 75: "snow", 77: "snow", + 80: "showers", 81: "showers", 82: "heavy_rain", + 85: "snow", 86: "snow", + 95: "thunder", 96: "thunder", 99: "thunder", +} + +# Emoji per bucket (leads the response line). +_BUCKET_EMOJI: dict[str, str] = { + "drizzle": "🌦️", + "rain": "🌧️", + "heavy_rain": "🌧️", + "freezing": "🧊", + "snow": "🌨️", + "showers": "🌦️", + "thunder": "⛈️", +} + +# English fallbacks for precip type labels (translations override via +# commands.rain.precip_types.; missing keys fall back to en.json). +_BUCKET_LABEL_EN: dict[str, str] = { + "drizzle": "Drizzle", + "rain": "Rain", + "heavy_rain": "Heavy rain", + "freezing": "Freezing rain", + "snow": "Snow", + "showers": "Showers", + "thunder": "Thunderstorms", +} + +# Upper bound on the per-instance geocoding caches so a long-running bot that's +# queried for many distinct locations can't grow them without limit. +_GEOCODE_CACHE_CAP = 256 + + +def _cache_put(cache: dict, key: Any, value: Any) -> None: + """Insert into a size-capped cache, evicting the oldest entry when full. + + Relies on dicts preserving insertion order (Python 3.7+). + """ + if key not in cache and len(cache) >= _GEOCODE_CACHE_CAP: + cache.pop(next(iter(cache))) + cache[key] = value + + +def precip_bucket_for_code(code: Optional[int]) -> Optional[str]: + """Map a WMO weather code to a precipitation bucket, or None if not precip.""" + if code is None: + return None + try: + return _PRECIP_BUCKETS.get(int(code)) + except (TypeError, ValueError): + return None + + +def titlecase_location(text: str) -> str: + """Tidy a user-typed location for display. + + 'middlesboro, ky' -> 'Middlesboro, KY'; 'paris, france' -> 'Paris, France'; + 'memphis' -> 'Memphis'. A 2-letter token after a comma is treated as a + state/country code and upper-cased; everything else is title-cased. + """ + parts = [p.strip() for p in text.split(",") if p.strip()] + if not parts: + return text.strip() + out = [] + for i, p in enumerate(parts): + if i > 0 and len(p) == 2 and p.isalpha(): + out.append(p.upper()) + else: + out.append(p.title()) + return ", ".join(out) + + +# US state / territory 2-letter codes — used to drop a trailing state from a +# typed location like "london ky" (no comma) so it doesn't become "London Ky". +US_STATE_ABBRS = frozenset({ + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", + "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", + "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", + "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", + "DC", "AS", "GU", "MP", "PR", "VI", +}) + + +def city_display_name(typed_location: str, suffix: Optional[str] = None) -> str: + """City part of a typed location for display, dropping a trailing region the + user appended without a comma. + + 'london ky' -> 'London'; 'paris france' -> 'Paris'; 'london, ky' -> 'London'; + 'oklahoma city' -> 'Oklahoma City'. `suffix` is the geocoder's authoritative + state/country (e.g. 'KY' or 'France'); when the typed text ends with it, it's + stripped so it isn't doubled into the city name. The state/country is added + back separately by the caller. + """ + head = typed_location.split(",")[0].strip() + # Drop a trailing region matching the geocoder's suffix — handles country + # names and multi-word regions ("paris france", "london united kingdom"). + if suffix and head.lower().endswith(" " + suffix.lower()): + head = head[: -len(suffix)].strip() + # Drop a trailing US state abbreviation ("london ky" -> "london"). + tokens = head.split() + if len(tokens) >= 2 and tokens[-1].upper() in US_STATE_ABBRS: + head = " ".join(tokens[:-1]) + return titlecase_location(head) + + +def reverse_geocode_region( + bot: Any, lat: float, lon: float, *, timeout: int = 10, logger: Any = None +) -> tuple[Optional[str], Optional[str]]: + """Reverse-geocode to (city, suffix), respecting the bot's Nominatim rate limiter. + + suffix is the US state abbreviation ('TN') for US points, else the English + country name ('Japan'). Requests language='en' so country names aren't + localized. No caching (callers cache as needed). Shared by the rain command + and the Weather_Service proactive push so both label locations identically. + """ + city: Optional[str] = None + suffix: Optional[str] = None + try: + from ..utils import get_nominatim_geocoder + limiter = getattr(bot, "nominatim_rate_limiter", None) + if limiter is not None: + limiter.wait_for_request_sync() + geolocator = get_nominatim_geocoder(timeout=timeout) + # language="en" so country names come back in English ("Japan", not "日本"). + result = geolocator.reverse(f"{lat}, {lon}", timeout=timeout, language="en") + if limiter is not None: + limiter.record_request() + if result is not None and hasattr(result, "raw"): + address = result.raw.get("address", {}) + city = ( + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality") + or address.get("county") + or None + ) + country_code = (address.get("country_code") or "").lower() + if country_code == "us": + iso = address.get("ISO3166-2-lvl4") or address.get("ISO3166-2-lvl6") or "" + if "-" in iso: + suffix = iso.rsplit("-", 1)[-1] + else: + state_abbr, _ = normalize_us_state(address.get("state", "")) + suffix = state_abbr or address.get("state") or None + else: + suffix = address.get("country") or None + except Exception as e: + if logger: + logger.debug(f"Error reverse geocoding {lat},{lon}: {e}") + return city, suffix + + +def precip_descriptor(bucket: Optional[str]) -> tuple[str, str]: + """Return (emoji, English label) for a precip bucket; defaults to rain. + + The label is English; localized callers use the commands.rain.precip_types + translation keys instead. Shared so the Weather_Service can build proactive + nowcast messages without duplicating the bucket tables. + """ + b = bucket or "rain" + return _BUCKET_EMOJI.get(b, "🌧️"), _BUCKET_LABEL_EN.get(b, "Rain") + + +def fetch_precip_series( + session: Any, + lat: float, + lon: float, + *, + weather_model: str = "", + timeout: int = 10, + logger: Any = None, +) -> Optional[dict]: + """Fetch + normalize an Open-Meteo precipitation series using `session`. + + Prefers 15-minutely data; falls back to hourly when a model doesn't provide + minutely_15. The caller owns the session's lifecycle. Returns a dict with + keys times, precip, codes, now, current_precip, current_code, step — or None + on any error. Precipitation is requested in mm (detection is unit-independent). + """ + api_url = "https://api.open-meteo.com/v1/forecast" + params: dict[str, Any] = { + "latitude": lat, + "longitude": lon, + "minutely_15": "precipitation,weather_code", + "hourly": "precipitation,weather_code", + "current": "precipitation,weather_code", + "precipitation_unit": "mm", + "timezone": "auto", + "forecast_days": 2, # cover the window even when "now" is late in the day + } + if weather_model: + params["models"] = weather_model + + try: + response = session.get(api_url, params=params, timeout=timeout) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if logger: + logger.debug(f"Open-Meteo nowcast timeout/connection error: {e}") + return None + if not response.ok: + if logger: + logger.warning(f"Open-Meteo nowcast error: HTTP {response.status_code}") + return None + data = response.json() + + current = data.get("current", {}) or {} + now = current.get("time") + if not now: + return None + + m15 = data.get("minutely_15", {}) or {} + m_times = m15.get("time") or [] + m_precip = m15.get("precipitation") or [] + if m_times and any(p is not None for p in m_precip): + return { + "times": m_times, + "precip": m_precip, + "codes": m15.get("weather_code") or [], + "now": now, + "current_precip": current.get("precipitation"), + "current_code": current.get("weather_code"), + "step": 15, + } + + hourly = data.get("hourly", {}) or {} + h_times = hourly.get("time") or [] + if not h_times: + return None + return { + "times": h_times, + "precip": hourly.get("precipitation") or [], + "codes": hourly.get("weather_code") or [], + "now": now, + "current_precip": current.get("precipitation"), + "current_code": current.get("weather_code"), + "step": 60, + } + + +@dataclass +class NowcastResult: + """Outcome of a precipitation nowcast analysis. + + state is one of: + - "dry_clear": dry now, no precip within the window + - "dry_incoming": dry now, precip starts in `minutes` + - "raining_stopping": precip now, drops below threshold in `minutes` + - "raining_continuing": precip now, never clears within the window + """ + + state: str + minutes: Optional[int] = None # until start (dry_incoming) or stop (raining_stopping) + duration_minutes: Optional[int] = None # for dry_incoming: how long the precip lasts + open_ended: bool = False # precip extends past the analysis window + bucket: Optional[str] = None # precip bucket (drizzle/rain/snow/...) when raining/incoming + + +def _round5(minutes: float) -> int: + """Round to the nearest 5 minutes, with a floor of 5 for positive values.""" + r = int(round(minutes / 5.0) * 5) + if minutes > 0 and r < 5: + return 5 + return max(0, r) + + +def analyze_precip_nowcast( + times: list[str], + precip: list[Optional[float]], + codes: list[Optional[int]], + now_iso: str, + *, + window_minutes: int = 120, + threshold: float = 0.1, + current_precip: Optional[float] = None, + current_code: Optional[int] = None, +) -> Optional[NowcastResult]: + """Pure nowcast analysis over a precipitation time series. + + All times are naive ISO-8601 local strings (e.g. "2026-06-03T14:15") in the + same timezone as `now_iso`, so "now" is derived from the API rather than the + host clock. Returns None when the series is too sparse to analyze. + + Args: + times: Bucket start times (ascending), ISO local strings. + precip: Precipitation amount per bucket, in mm. None is treated as 0. + codes: WMO weather code per bucket (parallel to `times`); used for the + precip type only. + now_iso: Current time, ISO local string (from the API's current.time). + window_minutes: How far ahead to look. + threshold: mm-per-bucket at/above which a bucket counts as precipitating. + current_precip: Optional instantaneous precip (API current.precipitation); + preferred over the bucket value for the "raining right now" decision. + current_code: Optional current WMO code, used for the precip type when + raining now. + """ + if not times or not precip: + return None + n = min(len(times), len(precip)) + try: + now = datetime.fromisoformat(now_iso) + except (TypeError, ValueError): + return None + + parsed: list[tuple[datetime, float, Optional[int]]] = [] + for i in range(n): + try: + t = datetime.fromisoformat(times[i]) + except (TypeError, ValueError): + continue + amt = precip[i] + amt = 0.0 if amt is None else float(amt) + code = codes[i] if i < len(codes) else None + parsed.append((t, amt, code)) + if not parsed: + return None + parsed.sort(key=lambda x: x[0]) + + # Index of the bucket containing "now" (largest start time <= now). + cur_idx = -1 + for i, (t, _amt, _c) in enumerate(parsed): + if t <= now: + cur_idx = i + else: + break + + # Upcoming buckets strictly after now, within the window. + upcoming: list[tuple[float, float, Optional[int]]] = [] # (minutes_from_now, amount, code) + for t, amt, code in parsed[cur_idx + 1:]: + mins = (t - now).total_seconds() / 60.0 + if mins <= 0: + continue + if mins > window_minutes: + break + upcoming.append((mins, amt, code)) + + # "Raining now?" — prefer the API's instantaneous value, else the current bucket. + if current_precip is not None: + raining_now = float(current_precip) >= threshold + elif cur_idx >= 0: + raining_now = parsed[cur_idx][1] >= threshold + else: + raining_now = False + + if raining_now: + now_code = current_code if current_code is not None else (parsed[cur_idx][2] if cur_idx >= 0 else None) + bucket = precip_bucket_for_code(now_code) or "rain" + for mins, amt, _code in upcoming: + if amt < threshold: + return NowcastResult(state="raining_stopping", minutes=_round5(mins), bucket=bucket) + return NowcastResult(state="raining_continuing", open_ended=True, bucket=bucket) + + # Dry now: find the first upcoming precipitating bucket. + for idx, (mins, amt, code) in enumerate(upcoming): + if amt >= threshold: + bucket = precip_bucket_for_code(code) or "rain" + # How long does it last? Walk until it drops below threshold. + end_mins: Optional[float] = None + for mins2, amt2, _c2 in upcoming[idx + 1:]: + if amt2 < threshold: + end_mins = mins2 + break + if end_mins is None: + return NowcastResult( + state="dry_incoming", minutes=_round5(mins), open_ended=True, bucket=bucket + ) + return NowcastResult( + state="dry_incoming", + minutes=_round5(mins), + duration_minutes=_round5(end_mins - mins), + bucket=bucket, + ) + + return NowcastResult(state="dry_clear") + + +def decide_rain_notification( + state: str, + minutes: Optional[int], + *, + lead_minutes: int, + start_announced: bool, + end_announced: bool, + seconds_since_last_start: Optional[float] = None, + seconds_since_last_end: Optional[float] = None, + renotify_minutes: int, + announce_ending: bool = True, +) -> tuple[Optional[str], bool, bool]: + """Decide whether to push a proactive rain notice, and which kind. + + A small state machine that keeps the Weather_Service from spamming a channel + every poll. Returns ``(kind, start_announced, end_announced)`` where ``kind`` + is ``None``, ``"starting"`` (rain about to begin), or ``"ending"`` (rain about + to stop); the two booleans are the caller's updated per-episode flags. + + - ``dry_clear`` ends the episode and re-arms both notices. + - ``dry_incoming`` fires ``"starting"`` once when precip enters the + ``lead_minutes`` window. + - ``raining_stopping`` fires ``"ending"`` once when the clear-up enters the + window (unless ``announce_ending`` is False). + - Each notice fires at most once per episode; a ``renotify_minutes`` cooldown + (tracked separately per kind) absorbs forecast flapping. + """ + if state == "dry_clear": + return (None, False, False) + + if state == "dry_incoming": + if minutes is None or minutes > lead_minutes: + return (None, start_announced, end_announced) # coming, but not yet within lead + if start_announced: + return (None, True, end_announced) + if seconds_since_last_start is not None and seconds_since_last_start < renotify_minutes * 60: + return (None, start_announced, end_announced) # cooldown: hold off, stay re-armed + return ("starting", True, end_announced) + + # Raining now: the "starting" moment has passed (or was missed) — mark it so a + # late "starting" never fires. + if state == "raining_continuing": + return (None, True, end_announced) + + if state == "raining_stopping": + if not announce_ending or minutes is None or minutes > lead_minutes: + return (None, True, end_announced) + if end_announced: + return (None, True, True) + if seconds_since_last_end is not None and seconds_since_last_end < renotify_minutes * 60: + return (None, True, end_announced) + return ("ending", True, True) + + return (None, start_announced, end_announced) + + +class RainCommand(BaseCommand): + """Minute-level rain nowcast for a location (Open-Meteo 15-minutely precip).""" + + name = "rain" + keywords = ["rain", "nowcast"] + description = "Rain nowcast: when precipitation starts or stops in the next couple hours" + category = "weather" + requires_internet = True + cooldown_seconds = 5 + + short_description = "Rain nowcast (when rain starts/stops) for a location" + usage = "rain [city|zipcode|lat,lon]" + examples = ["rain", "rain seattle", "rain 98101", "rain 47.6,-122.3"] + parameters = [ + {"name": "location", "description": "Optional: city, US ZIP, or lat,lon. Default: companion or bot location."} + ] + + def __init__(self, bot: Any) -> None: + super().__init__(bot) + self.rain_enabled = self.get_config_value("Rain_Command", "enabled", fallback=True, value_type="bool") + self.default_state = self.bot.config.get("Weather", "default_state", fallback="") + self.default_country = self.bot.config.get("Weather", "default_country", fallback="US") + self.weather_model = self.bot.config.get("Weather", "weather_model", fallback="").strip() + self.url_timeout = 10 + self.window_minutes = self.get_config_value( + "Rain_Command", "window_minutes", fallback=120, value_type="int" + ) + # mm-per-15min at/above which a bucket counts as precipitating. + self.threshold_mm = self.get_config_value( + "Rain_Command", "precip_threshold_mm", fallback=0.1, value_type="float" + ) + # Display names. The bot's own location prefers [Weather] default_city + + # default_state; other coordinates are reverse-geocoded (state for US, + # country otherwise). Results cached. + self.default_city = self.bot.config.get("Weather", "default_city", fallback="").strip() + # US ZIP -> city via Zippopotam.us. Opt-out for anyone who'd rather not + # add the external lookup; falls back to reverse geocoding when disabled. + self.zip_city_lookup = self.get_config_value( + "Rain_Command", "zip_city_lookup", fallback=True, value_type="bool" + ) + self._reverse_cache: dict[str, tuple[Optional[str], Optional[str]]] = {} + self._zip_cache: dict[str, str] = {} + + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + if not self.rain_enabled: + return False + return super().can_execute(message) + + def _create_retry_session(self) -> requests.Session: + """Session with light retry/backoff for the Open-Meteo call.""" + session = requests.Session() + retry_strategy = Retry( + total=2, + backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504], + allowed_methods=["GET"], + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=10, pool_maxsize=20) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + def _get_companion_location(self, message: MeshMessage) -> Optional[tuple[float, float]]: + """Get companion/sender location from the contact-tracking database.""" + try: + sender_pubkey = getattr(message, "sender_pubkey", None) + if not sender_pubkey: + return None + query = """ + SELECT latitude, longitude + FROM complete_contact_tracking + WHERE public_key = ? + AND latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude != 0 AND longitude != 0 + ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + LIMIT 1 + """ + results = self.bot.db_manager.execute_query(query, (sender_pubkey,)) + if results: + row = results[0] + return (float(row["latitude"]), float(row["longitude"])) + return None + except Exception as e: + self.logger.debug(f"Error getting companion location: {e}") + return None + + def _get_bot_location(self) -> Optional[tuple[float, float]]: + """Get bot location from config ([Bot] bot_latitude, bot_longitude).""" + try: + lat = self.bot.config.getfloat("Bot", "bot_latitude", fallback=None) + lon = self.bot.config.getfloat("Bot", "bot_longitude", fallback=None) + if lat is not None and lon is not None and -90 <= lat <= 90 and -180 <= lon <= 180: + return (lat, lon) + return None + except Exception as e: + self.logger.debug(f"Error getting bot location: {e}") + return None + + def _reverse_geocode(self, lat: float, lon: float) -> tuple[Optional[str], Optional[str]]: + """Reverse-geocode to (city, suffix), cached. suffix is the US state + abbreviation ('TN') for US points, else the country name ('Colombia').""" + key = f"{lat:.3f},{lon:.3f}" + if key in self._reverse_cache: + return self._reverse_cache[key] + city, suffix = reverse_geocode_region(self.bot, lat, lon, timeout=self.url_timeout, logger=self.logger) + if city or suffix: + _cache_put(self._reverse_cache, key, (city, suffix)) + return city, suffix + + def _coordinates_to_location_string(self, lat: float, lon: float) -> Optional[str]: + """'City, ST' (US) or 'City, Country' (non-US) from reverse geocoding.""" + city, suffix = self._reverse_geocode(lat, lon) + if not city: + return None + return f"{city}, {suffix}" if suffix else city + + def _suffix_for_coords(self, lat: float, lon: float) -> Optional[str]: + """US state abbreviation or country name for coordinates (enriches a known city).""" + return self._reverse_geocode(lat, lon)[1] + + def _zip_to_city_string(self, zipcode: str) -> Optional[str]: + """US ZIP -> 'City, ST' via Zippopotam.us (free, no key, cached). + + OSM/Nominatim often lacks the USPS city for a ZIP centroid (returns the + county instead), so for 5-digit US ZIPs this gives a far better name. + Returns None on failure (caller falls back to reverse geocoding). + """ + z = zipcode.strip() + if z in self._zip_cache: + return self._zip_cache[z] + name: Optional[str] = None + try: + resp = requests.get(f"https://api.zippopotam.us/us/{z}", timeout=self.url_timeout) + if resp.ok: + places = resp.json().get("places") or [] + if places: + city = (places[0].get("place name") or "").strip() + st = (places[0].get("state abbreviation") or "").strip() + if city: + name = f"{city}, {st}" if st else city + except Exception as e: + self.logger.debug(f"Zippopotam ZIP lookup failed for {z}: {e}") + if name: + _cache_put(self._zip_cache, z, name) + return name + + def _resolve_location( + self, message: MeshMessage, location: Optional[str] + ) -> tuple[Optional[float], Optional[float], Optional[str], Optional[str]]: + """Resolve to (lat, lon, location_label, error_key). + + Mirrors the aurora command: no input falls back to companion location, + then a [Rain_Command] default, then the bot location. Coordinate-based + labels are reverse-geocoded to a city name for display. + """ + if not location or not location.strip(): + co = self._get_companion_location(message) + if co: + label = self._coordinates_to_location_string(co[0], co[1]) or f"{co[0]:.1f},{co[1]:.1f}" + return (co[0], co[1], label, None) + default_lat = default_lon = None + if self.bot.config.has_section("Rain_Command"): + default_lat = self.bot.config.getfloat("Rain_Command", "default_lat", fallback=None) + default_lon = self.bot.config.getfloat("Rain_Command", "default_lon", fallback=None) + if default_lat is not None and default_lon is not None: + if -90 <= default_lat <= 90 and -180 <= default_lon <= 180: + label = self._coordinates_to_location_string(default_lat, default_lon) or f"{default_lat:.1f},{default_lon:.1f}" + return (default_lat, default_lon, label, None) + bot_loc = self._get_bot_location() + if bot_loc: + # Prefer the configured default city + state for the bot's own location. + if self.default_city: + suffix = self.default_state or self._suffix_for_coords(bot_loc[0], bot_loc[1]) + label = f"{self.default_city}, {suffix}" if suffix else self.default_city + else: + label = self._coordinates_to_location_string(bot_loc[0], bot_loc[1]) or f"{bot_loc[0]:.1f},{bot_loc[1]:.1f}" + return (bot_loc[0], bot_loc[1], label, None) + return (None, None, None, "commands.rain.no_location") + + loc = location.strip() + # Declared Optional up front: the coordinate branch assigns floats while + # the ZIP/city geocoders return Optional[float]; each is narrowed before use. + lat: Optional[float] + lon: Optional[float] + + # Coordinates "lat,lon" + if re.match(r"^\s*-?\d+\.?\d*\s*,\s*-?\d+\.?\d*\s*$", loc): + try: + a, b = loc.split(",", 1) + lat, lon = float(a.strip()), float(b.strip()) + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return (None, None, None, "commands.rain.error") + return (lat, lon, self._coordinates_to_location_string(lat, lon) or loc, None) + except ValueError: + return (None, None, None, "commands.rain.error") + + # US ZIP (5 digits) + if re.match(r"^\s*\d{5}\s*$", loc): + lat, lon = geocode_zipcode_sync( + self.bot, loc, default_country=self.default_country, timeout=self.url_timeout + ) + if lat is None or lon is None: + return (None, None, None, "commands.rain.no_location_zipcode") + # Name the ZIP "City, ST (zip)": Zippopotam first (reliable USPS city), + # then reverse geocoding, else just the ZIP. + zip_city = self._zip_to_city_string(loc) if self.zip_city_lookup else None + city = zip_city or self._coordinates_to_location_string(lat, lon) + label = f"{city} ({loc})" if city else loc + return (lat, lon, label, None) + + # City + lat, lon, _ = geocode_city_sync( + self.bot, + loc, + default_state=self.default_state, + default_country=self.default_country, + include_address_info=False, + timeout=self.url_timeout, + ) + if lat is None or lon is None: + return (None, None, None, "commands.rain.no_location_city") + # Keep the typed city name (more accurate than reverse geocoding for small + # towns), but append the state (US) or country (non-US) from the geocoder + # — stripping any region the user already typed so it isn't doubled. + suffix = self._suffix_for_coords(lat, lon) + typed_city = city_display_name(loc, suffix) + label = f"{typed_city}, {suffix}" if suffix else typed_city + return (lat, lon, label, None) + + def _fetch_series(self, lat: float, lon: float) -> Optional[dict]: + """Fetch the precip series via the shared fetcher (own short-lived session).""" + session = self._create_retry_session() + try: + return fetch_precip_series( + session, lat, lon, + weather_model=self.weather_model, timeout=self.url_timeout, logger=self.logger, + ) + finally: + session.close() + + def _window_label(self) -> str: + """Human window length, e.g. '2h' or '90min'.""" + if self.window_minutes % 60 == 0: + return f"{self.window_minutes // 60}h" + return f"{self.window_minutes}min" + + def _ptype(self, bucket: Optional[str]) -> str: + """Translatable precip-type label for a bucket.""" + b = bucket or "rain" + return self.translate(f"commands.rain.precip_types.{b}") + + def _format_result(self, result: NowcastResult, location_label: str) -> str: + """Render a NowcastResult into a single mesh-friendly line.""" + emoji = _BUCKET_EMOJI.get(result.bucket or "rain", "🌧️") + if result.state == "dry_clear": + return self.translate( + "commands.rain.clear", window=self._window_label(), location=location_label + ) + if result.state == "dry_incoming": + if result.open_ended or not result.duration_minutes: + extra = self.translate("commands.rain.duration_open") + else: + extra = self.translate("commands.rain.duration_for", duration=result.duration_minutes) + return self.translate( + "commands.rain.starting", + emoji=emoji, + ptype=self._ptype(result.bucket), + minutes=result.minutes, + location=location_label, + extra=extra, + ) + if result.state == "raining_stopping": + return self.translate( + "commands.rain.stopping", + emoji=emoji, + ptype=self._ptype(result.bucket), + minutes=result.minutes, + location=location_label, + ) + # raining_continuing + return self.translate( + "commands.rain.continuing", + emoji=emoji, + ptype=self._ptype(result.bucket), + window=self._window_label(), + location=location_label, + ) + + async def execute(self, message: MeshMessage) -> bool: + content = message.content.strip() + if content.startswith("!"): + content = content[1:].strip() + parts = content.split() + location: Optional[str] = " ".join(parts[1:]).strip() if len(parts) >= 2 else None + + lat, lon, location_label, err_key = self._resolve_location(message, location) + if lat is None or lon is None: + region = self.default_state or self.default_country + if err_key == "commands.rain.no_location": + await self.send_response(message, self.translate("commands.rain.no_location")) + elif err_key == "commands.rain.no_location_zipcode": + await self.send_response( + message, self.translate("commands.rain.no_location_zipcode", location=location or "") + ) + elif err_key == "commands.rain.no_location_city": + await self.send_response( + message, + self.translate("commands.rain.no_location_city", location=location or "", state=region), + ) + else: + await self.send_response( + message, self.translate("commands.rain.error", error="Invalid location or coordinates") + ) + return True + + try: + self.record_execution(message.sender_id) + loop = asyncio.get_event_loop() + series = await loop.run_in_executor(None, lambda: self._fetch_series(lat, lon)) + except Exception as e: + self.logger.error(f"Error fetching rain nowcast: {e}") + await self.send_response(message, self.translate("commands.rain.error_fetching")) + return True + + if not series: + await self.send_response(message, self.translate("commands.rain.error_fetching")) + return True + + result = analyze_precip_nowcast( + series["times"], + series["precip"], + series["codes"], + series["now"], + window_minutes=self.window_minutes, + threshold=self.threshold_mm, + current_precip=series.get("current_precip"), + current_code=series.get("current_code"), + ) + if result is None: + await self.send_response(message, self.translate("commands.rain.error_fetching")) + return True + + response = self._format_result(result, location_label or f"{lat:.1f},{lon:.1f}") + max_len = self.get_max_message_length(message) + if len(response) > max_len: + response = response[: max_len - 3] + "..." + await self.send_response(message, response) + return True diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 5fc299d..e99a1bd 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -30,6 +30,13 @@ except ImportError: import contextlib +from ..commands.rain_command import ( + analyze_precip_nowcast, + decide_rain_notification, + fetch_precip_series, + precip_descriptor, + reverse_geocode_region, +) from ..url_shortener import shorten_url from ..utils import format_temperature_high_low, get_config_timezone from .base_service import BaseServicePlugin @@ -89,6 +96,17 @@ class WeatherService(BaseServicePlugin): self.wind_speed_unit = self.bot.config.get('Weather', 'wind_speed_unit', fallback='mph') self.precipitation_unit = self.bot.config.get('Weather', 'precipitation_unit', fallback='inch') + # Proactive rain nowcast ("rain incoming" push). Reuses the rain command's + # Open-Meteo 15-minutely logic for the bot's own position. + self.rain_nowcast_enabled = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_enabled', fallback=False) + self.rain_channel = self.bot.config.get('Weather_Service', 'rain_channel', fallback=self.weather_channel) + self.poll_rain_nowcast_interval = self.bot.config.getint('Weather_Service', 'poll_rain_nowcast_interval', fallback=900000) / 1000.0 + self.rain_nowcast_lead_minutes = self.bot.config.getint('Weather_Service', 'rain_nowcast_lead_minutes', fallback=60) + self.rain_nowcast_renotify_minutes = self.bot.config.getint('Weather_Service', 'rain_nowcast_renotify_minutes', fallback=30) + self.rain_nowcast_threshold_mm = self.bot.config.getfloat('Weather_Service', 'rain_nowcast_threshold_mm', fallback=0.1) + # Also announce when rain is about to stop (not just start). + self.rain_nowcast_announce_ending = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_announce_ending', fallback=True) + # Track seen alerts to avoid duplicates self.seen_alert_ids: set[str] = set() @@ -99,9 +117,20 @@ class WeatherService(BaseServicePlugin): self._alerts_task: Optional[asyncio.Task] = None self._forecast_task: Optional[asyncio.Task] = None self._lightning_task: Optional[asyncio.Task] = None + self._rain_task: Optional[asyncio.Task] = None self._forecast_scheduler: Optional[BackgroundScheduler] = None self._running = False + # Rain nowcast episode state (dedup): which notice has fired for the + # current rain episode, and the last-push timestamps (cooldown backstop). + self._rain_start_announced = False + self._rain_end_announced = False + self._last_rain_start_time: Optional[float] = None + self._last_rain_end_time: Optional[float] = None + # "City, ST" / "City, Country" for the proactive push (cached separately + # from the daily-forecast location name). + self._cached_rain_location: Optional[str] = None + # Track recent lightning strikes to avoid duplicates self.recent_lightning_strikes: set[str] = set() @@ -216,6 +245,12 @@ class WeatherService(BaseServicePlugin): # Start background tasks self._alerts_task = asyncio.create_task(self._poll_weather_alerts_loop()) + # Start proactive rain nowcast polling + if self.rain_nowcast_enabled: + self._rain_task = asyncio.create_task(self._poll_rain_nowcast_loop()) + else: + self._rain_task = None + # Start lightning detection if area is configured if self.blitz_area and MQTT_AVAILABLE: self._lightning_task = asyncio.create_task(self._poll_lightning_loop()) @@ -252,6 +287,11 @@ class WeatherService(BaseServicePlugin): with contextlib.suppress(asyncio.CancelledError): await self._lightning_task + if self._rain_task: + self._rain_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._rain_task + if self.mqtt_task: self.mqtt_task.cancel() with contextlib.suppress(asyncio.CancelledError): @@ -766,6 +806,110 @@ class WeatherService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error checking weather alerts: {e}") + async def _poll_rain_nowcast_loop(self) -> None: + """Background task: poll for incoming rain and push a heads-up once per episode.""" + self.logger.info( + f"Starting rain nowcast polling (interval: {self.poll_rain_nowcast_interval}s, " + f"lead: {self.rain_nowcast_lead_minutes}min)" + ) + while self._running: + try: + await self._check_rain_nowcast() + await asyncio.sleep(self.poll_rain_nowcast_interval) + except asyncio.CancelledError: + break + except Exception as e: + self.logger.error(f"Error in rain nowcast polling loop: {e}") + await asyncio.sleep(60) # Wait 1 minute on error before retrying + + async def _check_rain_nowcast(self) -> None: + """Fetch the precip nowcast for the bot's position and push if rain is incoming.""" + try: + loop = asyncio.get_event_loop() + series = await loop.run_in_executor( + None, + lambda: fetch_precip_series( + self.api_session, + self.my_position_lat, + self.my_position_lon, + weather_model=self.weather_model or "", + timeout=10, + logger=self.logger, + ), + ) + if not series: + return + + # Look at least as far ahead as the lead window (plus a margin so we can + # estimate how long the rain lasts). + window = max(120, self.rain_nowcast_lead_minutes + 15) + result = analyze_precip_nowcast( + series["times"], series["precip"], series["codes"], series["now"], + window_minutes=window, threshold=self.rain_nowcast_threshold_mm, + current_precip=series.get("current_precip"), current_code=series.get("current_code"), + ) + if result is None: + return + + now_ts = time.time() + since_start = None if self._last_rain_start_time is None else (now_ts - self._last_rain_start_time) + since_end = None if self._last_rain_end_time is None else (now_ts - self._last_rain_end_time) + kind, self._rain_start_announced, self._rain_end_announced = decide_rain_notification( + result.state, + result.minutes, + lead_minutes=self.rain_nowcast_lead_minutes, + start_announced=self._rain_start_announced, + end_announced=self._rain_end_announced, + seconds_since_last_start=since_start, + seconds_since_last_end=since_end, + renotify_minutes=self.rain_nowcast_renotify_minutes, + announce_ending=self.rain_nowcast_announce_ending, + ) + if kind is None: + return + + message = await self._format_rain_nowcast(result, kind) + await self.bot.command_manager.send_channel_message( + self.rain_channel, + message, + scope=self.get_mesh_flood_scope(), + ) + if kind == "starting": + self._last_rain_start_time = now_ts + else: + self._last_rain_end_time = now_ts + self.logger.info(f"Rain nowcast ({kind}) sent to {self.rain_channel}: {message}") + + except Exception as e: + self.logger.error(f"Error checking rain nowcast: {e}") + + async def _format_rain_nowcast(self, result: Any, kind: str) -> str: + """Build the proactive heads-up line (English, mesh-friendly). + + kind is "starting" (rain incoming) or "ending" (rain about to stop). + """ + emoji, ptype = precip_descriptor(result.bucket) + + # City + state/country (same labeling as the !rain command), reverse- + # geocoded once and cached. Kept separate from the daily-forecast cache. + if self._cached_rain_location is None: + loop = asyncio.get_event_loop() + city, suffix = await loop.run_in_executor( + None, + lambda: reverse_geocode_region( + self.bot, self.my_position_lat, self.my_position_lon, timeout=10, logger=self.logger + ), + ) + self._cached_rain_location = (f"{city}, {suffix}" if suffix else city) if city else "" + location = f" near {self._cached_rain_location}" if self._cached_rain_location else "" + + if kind == "ending": + return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{location}" + # Flag prolonged rain ("steady") rather than a numeric duration, which + # would sit confusingly next to the minutes-until-start value. + steady = " (steady)" if result.open_ended else "" + return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{steady}{location}" + async def _connect_blitzortung_mqtt(self) -> None: """Connect to Blitzortung MQTT broker and subscribe to lightning data. diff --git a/pyproject.toml b/pyproject.toml index ada13c8..a87f93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,7 @@ module = [ "modules.plugin_loader", "modules.security_utils", "modules.commands.base_command", + "modules.commands.rain_command", ] disallow_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/unit/test_rain_nowcast.py b/tests/unit/test_rain_nowcast.py new file mode 100644 index 0000000..173bb68 --- /dev/null +++ b/tests/unit/test_rain_nowcast.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Unit tests for the rain-nowcast pure logic (no network). + +Exercises analyze_precip_nowcast / precip_bucket_for_code / _round5 with +synthetic 15-minutely (and hourly) precipitation series. "Now" is supplied +explicitly, matching how the command derives it from the API's current.time. +""" + +from modules.commands.rain_command import ( + NowcastResult, + _round5, # noqa: PLC2701 (testing internal helper) + analyze_precip_nowcast, + city_display_name, + decide_rain_notification, + precip_bucket_for_code, + precip_descriptor, + titlecase_location, +) + +NOW = "2026-06-03T14:00" + +# 9 ascending 15-min buckets starting at NOW (covers a 120-min window). +TIMES_15 = [ + "2026-06-03T14:00", + "2026-06-03T14:15", + "2026-06-03T14:30", + "2026-06-03T14:45", + "2026-06-03T15:00", + "2026-06-03T15:15", + "2026-06-03T15:30", + "2026-06-03T15:45", + "2026-06-03T16:00", +] + + +def _codes(n, code=61): + return [code] * n + + +# --- precip_bucket_for_code ------------------------------------------------- + +def test_bucket_mapping_basic(): + assert precip_bucket_for_code(61) == "rain" + assert precip_bucket_for_code(65) == "heavy_rain" + assert precip_bucket_for_code(82) == "heavy_rain" + assert precip_bucket_for_code(71) == "snow" + assert precip_bucket_for_code(86) == "snow" + assert precip_bucket_for_code(56) == "freezing" + assert precip_bucket_for_code(80) == "showers" + assert precip_bucket_for_code(95) == "thunder" + + +def test_bucket_mapping_non_precip_and_invalid(): + assert precip_bucket_for_code(0) is None # clear + assert precip_bucket_for_code(3) is None # overcast + assert precip_bucket_for_code(None) is None + assert precip_bucket_for_code("nope") is None + + +# --- _round5 ---------------------------------------------------------------- + +def test_round5(): + assert _round5(0) == 0 + assert _round5(2) == 5 # positive but rounds to 0 -> floor of 5 + assert _round5(7) == 5 + assert _round5(13) == 15 + assert _round5(30) == 30 + assert _round5(60) == 60 + + +# --- dry now ---------------------------------------------------------------- + +def test_dry_clear(): + precip = [0.0] * 9 + r = analyze_precip_nowcast(TIMES_15, precip, _codes(9, 0), NOW, window_minutes=120) + assert r.state == "dry_clear" + assert r.minutes is None + + +def test_dry_incoming_with_duration(): + # Dry now; rain at 14:30 and 14:45, dry again at 15:00. + precip = [0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0] + codes = [0, 0, 61, 61, 0, 0, 0, 0, 0] + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "dry_incoming" + assert r.minutes == 30 + assert r.duration_minutes == 30 # 14:30 -> 15:00 + assert r.open_ended is False + assert r.bucket == "rain" + + +def test_dry_incoming_open_ended(): + # Rain starts at 14:30 and never clears within the window. + precip = [0.0, 0.0] + [0.5] * 7 + codes = [0, 0] + [63] * 7 + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "dry_incoming" + assert r.minutes == 30 + assert r.open_ended is True + assert r.duration_minutes is None + + +def test_dry_incoming_snow_bucket(): + precip = [0.0, 0.0, 0.0, 0.4, 0.0, 0.0, 0.0, 0.0, 0.0] + codes = [0, 0, 0, 73, 0, 0, 0, 0, 0] + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "dry_incoming" + assert r.minutes == 45 + assert r.bucket == "snow" + + +# --- raining now ------------------------------------------------------------ + +def test_raining_stopping_from_bucket(): + # Raining at NOW (current bucket), clears at 14:30. + precip = [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + codes = [63, 63, 0, 0, 0, 0, 0, 0, 0] + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "raining_stopping" + assert r.minutes == 30 + assert r.bucket == "rain" + + +def test_raining_continuing(): + precip = [0.5] * 9 + codes = _codes(9, 65) + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "raining_continuing" + assert r.open_ended is True + assert r.bucket == "heavy_rain" + + +def test_current_precip_override_makes_it_raining(): + # Series bucket at NOW reads 0, but live current.precipitation says it's raining. + precip = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + codes = [0] * 9 + r = analyze_precip_nowcast( + TIMES_15, precip, codes, NOW, window_minutes=120, + current_precip=0.6, current_code=63, + ) + assert r.state == "raining_stopping" + assert r.minutes == 15 # first dry bucket is 14:15 + assert r.bucket == "rain" + + +# --- window boundary -------------------------------------------------------- + +def test_rain_at_window_edge_is_included(): + # Rain only at 16:00 == exactly 120 min out. + precip = [0.0] * 8 + [0.5] + codes = [0] * 8 + [61] + r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120) + assert r.state == "dry_incoming" + assert r.minutes == 120 + + +def test_rain_past_window_is_ignored(): + times = TIMES_15 + ["2026-06-03T16:15", "2026-06-03T16:30"] + precip = [0.0] * 9 + [0.8, 0.8] # rain only at 16:15 (135 min) and beyond + codes = [0] * 9 + [61, 61] + r = analyze_precip_nowcast(times, precip, codes, NOW, window_minutes=120) + assert r.state == "dry_clear" + + +# --- step-agnostic (hourly fallback shape) ---------------------------------- + +def test_hourly_series_detects_incoming(): + times = ["2026-06-03T14:00", "2026-06-03T15:00", "2026-06-03T16:00", "2026-06-03T17:00"] + precip = [0.0, 0.6, 0.0, 0.0] + codes = [0, 61, 0, 0] + r = analyze_precip_nowcast(times, precip, codes, NOW, window_minutes=180) + assert r.state == "dry_incoming" + assert r.minutes == 60 + assert r.bucket == "rain" + + +# --- robustness ------------------------------------------------------------- + +def test_empty_series_returns_none(): + assert analyze_precip_nowcast([], [], [], NOW) is None + + +def test_bad_now_returns_none(): + assert analyze_precip_nowcast(TIMES_15, [0.0] * 9, _codes(9, 0), "not-a-time") is None + + +def test_none_precip_treated_as_zero(): + precip = [None] * 9 + r = analyze_precip_nowcast(TIMES_15, precip, _codes(9, 0), NOW, window_minutes=120) + assert r.state == "dry_clear" + + +def test_threshold_respected(): + # 0.05mm buckets are below the default 0.1 threshold -> still dry. + precip = [0.0, 0.0, 0.05, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0] + r = analyze_precip_nowcast(TIMES_15, precip, _codes(9, 61), NOW, window_minutes=120) + assert r.state == "dry_clear" + # Lower the threshold and the same drizzle now registers. + r2 = analyze_precip_nowcast(TIMES_15, precip, _codes(9, 61), NOW, window_minutes=120, threshold=0.01) + assert r2.state == "dry_incoming" + assert r2.minutes == 30 + + +def test_result_is_dataclass(): + r = analyze_precip_nowcast(TIMES_15, [0.0] * 9, _codes(9, 0), NOW) + assert isinstance(r, NowcastResult) + + +# --- precip_descriptor ------------------------------------------------------ + +def test_titlecase_location(): + assert titlecase_location("middlesboro, ky") == "Middlesboro, KY" + assert titlecase_location("MIDDLESBORO, KY") == "Middlesboro, KY" + assert titlecase_location("memphis") == "Memphis" + assert titlecase_location("new york") == "New York" + assert titlecase_location("paris, france") == "Paris, France" + assert titlecase_location("nashville,tn") == "Nashville, TN" + assert titlecase_location("") == "" + + +def test_city_display_name(): + # Trailing US state code dropped whether or not there's a comma. + assert city_display_name("london ky") == "London" + assert city_display_name("london, ky") == "London" + assert city_display_name("LONDON KY") == "London" + assert city_display_name("oklahoma city ok") == "Oklahoma City" + assert city_display_name("new york ny") == "New York" + # No trailing state -> unchanged (multi-word cities preserved). + assert city_display_name("oklahoma city") == "Oklahoma City" + assert city_display_name("miami") == "Miami" + assert city_display_name("new york") == "New York" + # 'paris, france' -> city part only (country added separately by the geocoder). + assert city_display_name("paris, france") == "Paris" + + +def test_city_display_name_strips_suffix(): + # Trailing country / multi-word region matching the geocoder suffix is dropped. + assert city_display_name("paris france", "France") == "Paris" + assert city_display_name("london united kingdom", "United Kingdom") == "London" + assert city_display_name("paris ky", "KY") == "Paris" + assert city_display_name("medellin colombia", "Colombia") == "Medellin" + # Suffix that isn't actually trailing leaves the name intact. + assert city_display_name("miami", "FL") == "Miami" + assert city_display_name("san francisco", "CA") == "San Francisco" + + +def test_precip_descriptor(): + assert precip_descriptor("snow") == ("🌨️", "Snow") + assert precip_descriptor("heavy_rain") == ("🌧️", "Heavy rain") + assert precip_descriptor("thunder") == ("⛈️", "Thunderstorms") + # Unknown / None default to rain + assert precip_descriptor(None) == ("🌧️", "Rain") + assert precip_descriptor("bogus") == ("🌧️", "Rain") + + +# --- decide_rain_notification (proactive push state machine) ----------------- + +def _decide(state, minutes, *, start=False, end=False, since_start=None, since_end=None, + lead=60, renotify=30, announce_ending=True): + return decide_rain_notification( + state, minutes, lead_minutes=lead, start_announced=start, end_announced=end, + seconds_since_last_start=since_start, seconds_since_last_end=since_end, + renotify_minutes=renotify, announce_ending=announce_ending, + ) + + +def test_decide_dry_clear_rearms(): + # dry_clear always ends the episode (both flags -> False), never sends. + assert _decide("dry_clear", None, start=True, end=True) == (None, False, False) + assert _decide("dry_clear", None) == (None, False, False) + + +def test_decide_raining_continuing_marks_started(): + # Raining with no break in window: mark the start done, fire nothing here. + assert _decide("raining_continuing", None) == (None, True, False) + + +def test_decide_incoming_fresh_fires_starting(): + assert _decide("dry_incoming", 30) == ("starting", True, False) + + +def test_decide_incoming_already_announced_suppressed(): + assert _decide("dry_incoming", 30, start=True) == (None, True, False) + + +def test_decide_incoming_outside_lead_waits(): + assert _decide("dry_incoming", 90) == (None, False, False) + assert _decide("dry_incoming", 90, start=True) == (None, True, False) + + +def test_decide_incoming_none_minutes_waits(): + assert _decide("dry_incoming", None) == (None, False, False) + + +def test_decide_start_cooldown_holds_then_releases(): + assert _decide("dry_incoming", 20, since_start=5 * 60) == (None, False, False) + assert _decide("dry_incoming", 20, since_start=31 * 60) == ("starting", True, False) + + +# --- ending notice (symmetric "rain stopping") --- + +def test_decide_ending_fires_once(): + assert _decide("raining_stopping", 20) == ("ending", True, True) + # Already announced this episode -> suppressed. + assert _decide("raining_stopping", 20, start=True, end=True) == (None, True, True) + + +def test_decide_ending_outside_lead_waits(): + assert _decide("raining_stopping", 90) == (None, True, False) + + +def test_decide_ending_disabled_by_flag(): + assert _decide("raining_stopping", 20, announce_ending=False) == (None, True, False) + + +def test_decide_ending_cooldown_holds_then_releases(): + assert _decide("raining_stopping", 20, since_end=5 * 60) == (None, True, False) + assert _decide("raining_stopping", 20, since_end=31 * 60) == ("ending", True, True) + + +def test_decide_full_episode_sequence(): + """Simulate the service poll loop across a full episode: 'starting' once, + then 'ending' once, deduped across polls, both reset on clear.""" + start_ann, end_ann = False, False + last_start, last_end, clock = None, None, 0 + polls = [ + ("dry_clear", None), # quiet + ("dry_incoming", 30), # rain incoming -> starting + ("dry_incoming", 15), # dedup + ("raining_continuing", None), # raining, no break -> nothing + ("raining_stopping", 20), # clear-up incoming -> ending + ("raining_stopping", 10), # dedup + ("dry_clear", None), # cleared -> reset + ] + kinds = [] + for state, minutes in polls: + ss = None if last_start is None else (clock - last_start) + se = None if last_end is None else (clock - last_end) + kind, start_ann, end_ann = decide_rain_notification( + state, minutes, lead_minutes=60, start_announced=start_ann, end_announced=end_ann, + seconds_since_last_start=ss, seconds_since_last_end=se, renotify_minutes=30, + ) + if kind == "starting": + last_start = clock + elif kind == "ending": + last_end = clock + kinds.append(kind) + clock += 15 * 60 # advance 15 min between polls + + assert kinds == [None, "starting", None, None, "ending", None, None] diff --git a/translations/en.json b/translations/en.json index 07c7f58..c784fae 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,6 +6,7 @@ "wx": ["wx", "weather", "wxa", "wxalert"], "gwx": ["gwx", "globalweather", "gwxa"], "aqi": ["aqi", "air", "airquality", "air_quality"], + "rain": ["rain", "nowcast"], "aurora": ["aurora", "kp"], "solar": ["solar"], "sun": ["sun"], @@ -383,6 +384,31 @@ "comet": "Comets have thin atmospheres of water vapor and dust. AQI: Variable, but you'd freeze in space anyway. ☄️" } }, + "rain": { + "description": "Rain nowcast: when precipitation starts or stops in the next couple hours", + "usage": "Usage: rain [city|zipcode|lat,lon]", + "usage_short": "Usage: rain [city|zipcode|lat,lon]", + "starting": "{emoji} {ptype} starting in ~{minutes}min for {location}{extra}", + "stopping": "{emoji} {ptype} easing in ~{minutes}min for {location}", + "continuing": "{emoji} {ptype} steady for {window}+ in {location}", + "clear": "☀️ No rain expected in next {window} for {location}", + "duration_for": " (~{duration}min)", + "duration_open": " (steady)", + "error_fetching": "Error fetching rain nowcast data", + "no_location": "Usage: rain [city|zipcode|lat,lon]. No location: will use companion location if known, or bot location if not.", + "no_location_city": "Could not find city '{location}' in {state}", + "no_location_zipcode": "Could not find ZIP code '{location}'", + "error": "Error: {error}", + "precip_types": { + "drizzle": "Drizzle", + "rain": "Rain", + "heavy_rain": "Heavy rain", + "freezing": "Freezing rain", + "snow": "Snow", + "showers": "Showers", + "thunder": "Thunderstorms" + } + }, "aurora": { "description": "Get aurora forecast (KP index and probability) for a location", "usage": "Usage: aurora [city|zipcode|lat,lon]",