mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-09 00:51:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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]",
|
||||
|
||||
Reference in New Issue
Block a user