Merge pull request #193 from rlwilliamson-dev/feat/rain-nowcast

feat(rain): minute-level rain nowcast command + proactive incoming/ending push
This commit is contained in:
Adam Gessaman
2026-06-03 18:57:55 -07:00
committed by GitHub
9 changed files with 1461 additions and 0 deletions
+5
View File
@@ -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
+47
View File
@@ -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)
+34
View File
@@ -242,6 +242,40 @@ aqi help
---
### `rain <location>`
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.
+44
View File
@@ -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`:
+810
View File
@@ -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.<bucket>). 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.<bucket>; 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
+144
View File
@@ -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.
+1
View File
@@ -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
+350
View File
@@ -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]
+26
View File
@@ -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]",