feat(rain): nowcast enhancements (amounts, capitals, snow/ice, !snow, probability, changeover) + tests

Builds on the merged rain/snow nowcast (#193): ten enhancements plus
end-to-end, proactive, and live-smoke test coverage. All new behavior is
config-gated or additive, so existing deployments are unaffected by default.

Enhancements
- Precip amount estimate "(est 0.2 in)" on the command and the proactive push;
  snow shown as real depth (Open-Meteo snowfall, cm); freezing rain tagged "in ice".
- Bare country / US state resolves to its capital with a heads-up
  (self-contained modules/region_capitals.py; no pycountry/us dependency).
- join_location() dedupes "Spain, Spain" / city-states.
- !snow alias + neutral !nowcast; each looks for its own precip family across
  the window, else falls back with a "No snow, but rain ..." cross-type line.
- Keyword-aware help (help rain / help snow).
- Precip probability shown "(..., 70%)"; the proactive incoming alert is gated
  to >= [Weather_Service] rain_nowcast_min_probability (default 50).
- Rain<->snow changeover line when the window holds both families.
- Borderline temperature tag (30-38F).
- Short-lived series cache shared by the command and the proactive poll.

Tests
- test_rain_command_e2e.py: drives RainCommand.execute() end to end and asserts
  the exact rendered reply across dry/incoming/raining, snow depth, cross-type
  mismatch, changeover, ice, temp tag, region capitals, config toggles, DM
  budget, and keyword-aware help.
- test_rain_proactive_e2e.py: drives Weather_Service._check_rain_nowcast() for
  the probability gate, snow depth, ending notice, and once-per-episode dedup.
- test_rain_live_smoke.py: opt-in (RAIN_LIVE_SMOKE=1) live Open-Meteo check for
  upstream schema drift; skipped in CI.
- Shared scaffolding in tests/unit/_rain_harness.py.

New config: [Rain_Command] show_amount/amount_unit/show_probability/show_temp/
cache_seconds/zip_city_lookup; [Weather_Service] rain_nowcast_min_probability/
rain_nowcast_cache_seconds/rain_nowcast_show_amount/rain_nowcast_amount_unit.
ruff + mypy clean.
This commit is contained in:
rlwilliamson-dev
2026-06-06 16:18:52 -05:00
parent e5666b7cce
commit ffa5595fff
9 changed files with 1461 additions and 98 deletions
+373 -88
View File
@@ -6,8 +6,9 @@ Open-Meteo's 15-minutely precipitation forecast. Works worldwide, no API key.
import asyncio
import re
import time
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any, Optional
import requests
@@ -15,6 +16,7 @@ from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from ..models import MeshMessage
from ..region_capitals import REGION_DEFAULT_NOTE, region_capital_query
from ..utils import geocode_city_sync, geocode_zipcode_sync, normalize_us_state
from .base_command import BaseCommand
@@ -55,6 +57,13 @@ _BUCKET_LABEL_EN: dict[str, str] = {
"thunder": "Thunderstorms",
}
# Precip "families" for the !rain vs !snow modes. A command looks for its own
# family across the window first; only if none is coming does it fall back to
# the other type with a "No <type>, but ..." heads-up. Freezing rain is liquid,
# so it lives in the rain family (a !snow ice-event reads "No snow, but ...").
RAIN_FAMILY = frozenset({"drizzle", "rain", "heavy_rain", "showers", "thunder", "freezing"})
SNOW_FAMILY = frozenset({"snow"})
# 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
@@ -132,6 +141,23 @@ def city_display_name(typed_location: str, suffix: Optional[str] = None) -> str:
return titlecase_location(head)
def join_location(city: Optional[str], suffix: Optional[str]) -> str:
"""Join a city and its state/country suffix as 'City, Suffix'.
Collapses to a single name when one side is missing or the two name the same
place (case-insensitive) — so a country typed as the city ('spain' -> 'Spain',
not 'Spain, Spain') or a city-state ('Singapore', not 'Singapore, Singapore')
renders once.
"""
city = (city or "").strip()
suffix = (suffix or "").strip()
if not suffix:
return city
if not city or city.lower() == suffix.lower():
return suffix
return f"{city}, {suffix}"
def reverse_geocode_region(
bot: Any, lat: float, lon: float, *, timeout: int = 10, logger: Any = None
) -> tuple[Optional[str], Optional[str]]:
@@ -191,6 +217,102 @@ def precip_descriptor(bucket: Optional[str]) -> tuple[str, str]:
return _BUCKET_EMOJI.get(b, "🌧️"), _BUCKET_LABEL_EN.get(b, "Rain")
def format_precip_amount(mm: Optional[float], unit: str = "in") -> Optional[str]:
"""Format an accumulated precip total (mm) for display, or None if negligible.
``unit`` "in" renders inches (the US default), anything else millimetres.
Returns None for a non-positive total so callers can omit the estimate
entirely. Inches trim trailing zeros: 0.20 -> "0.2 in", 0.05 -> "0.05 in".
"""
if mm is None or mm <= 0:
return None
if unit == "mm":
return f"{mm:.1f} mm" if mm >= 0.1 else "<0.1 mm"
inches = mm * 0.0393701
if inches < 0.01:
return "<0.01 in"
return f"{inches:.2f}".rstrip("0").rstrip(".") + " in"
def format_snow_amount(cm: Optional[float], unit: str = "in") -> Optional[str]:
"""Format a snowfall total (cm of actual snow) for display, or None if negligible.
Snow is reported as depth, not liquid equivalent (Open-Meteo's ``precipitation``
is the melted equivalent, ~7x less). ``unit`` "in" renders inches of snow (US
default), anything else centimetres. The trailing " snow" keeps it distinct
from the liquid rain estimate; 0.1 precision suits snow's coarseness.
"""
if cm is None or cm <= 0:
return None
if unit == "mm":
return f"{cm:.1f} cm snow" if cm >= 0.1 else "<0.1 cm snow"
inches = cm * 0.393701
if inches < 0.1:
return "<0.1 in snow"
return f"{inches:.1f}".rstrip("0").rstrip(".") + " in snow"
def format_amount_estimate(
bucket: Optional[str], amount_mm: Optional[float], snow_cm: Optional[float], unit: str = "in"
) -> Optional[str]:
"""The estimate string for a precip bucket, or None if negligible.
Picks the right quantity per type: snow shows depth ("3 in snow"); freezing
rain shows its liquid/glaze amount tagged "ice" ("0.1 in ice", ~1:1 with
accretion); everything else is plain liquid ("0.2 in").
"""
if bucket == "snow":
return format_snow_amount(snow_cm, unit)
amt = format_precip_amount(amount_mm, unit)
if amt and bucket == "freezing":
return f"{amt} ice"
return amt
def episode_probability_temp(series: dict, result: "NowcastResult") -> tuple[Optional[int], Optional[int]]:
"""Precip probability (%) and 2 m temperature (°F) at the episode's defining
moment — the current bucket when precipitating now, else the start bucket.
Returns (None, None) for dry_clear or when the data is missing.
"""
if result is None or result.state == "dry_clear":
return None, None
times = series.get("times") or []
probs = series.get("prob") or []
temps = series.get("temp") or []
try:
now = datetime.fromisoformat(series["now"])
except (TypeError, ValueError, KeyError):
return None, None
if result.state in ("raining_stopping", "raining_continuing"):
target = now
else: # dry_incoming: align with when precip begins
target = now + timedelta(minutes=result.minutes or 0)
best_i: Optional[int] = None
best_d: Optional[float] = None
for i, t in enumerate(times):
try:
tt = datetime.fromisoformat(t)
except (TypeError, ValueError):
continue
d = abs((tt - target).total_seconds())
if best_d is None or d < best_d:
best_d, best_i = d, i
if best_i is None:
return None, None
prob = probs[best_i] if best_i < len(probs) else None
tc = temps[best_i] if best_i < len(temps) else None
prob_pct = int(round(prob)) if prob is not None else None
temp_f = int(round(tc * 9 / 5 + 32)) if tc is not None else None
return prob_pct, temp_f
# Short-lived cache of fetched series, keyed by rounded coords + model, so the
# command and the proactive poll can reuse one fetch instead of re-hitting
# Open-Meteo. Bounded; entries expire by the caller's cache_ttl.
_SERIES_CACHE: dict[tuple, tuple[float, dict]] = {}
_SERIES_CACHE_CAP = 64
def fetch_precip_series(
session: Any,
lat: float,
@@ -199,21 +321,32 @@ def fetch_precip_series(
weather_model: str = "",
timeout: int = 10,
logger: Any = None,
cache_ttl: float = 0.0,
) -> 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).
minutely_15. The caller owns the session's lifecycle. Returns a dict with keys
times, precip, snow, prob, temp, codes, now, current_precip, current_code,
step — or None on any error. Precipitation is requested in mm (detection is
unit-independent); snowfall in cm; prob is precip probability (%); temp is
2 m temperature (°C). When cache_ttl > 0, a fresh prior result for the same
rounded location is reused.
"""
cache_key = (round(lat, 2), round(lon, 2), weather_model)
if cache_ttl > 0:
hit = _SERIES_CACHE.get(cache_key)
if hit is not None and (time.time() - hit[0]) < cache_ttl:
return hit[1]
api_url = "https://api.open-meteo.com/v1/forecast"
variables = "precipitation,snowfall,weather_code,precipitation_probability,temperature_2m"
params: dict[str, Any] = {
"latitude": lat,
"longitude": lon,
"minutely_15": "precipitation,weather_code",
"hourly": "precipitation,weather_code",
"current": "precipitation,weather_code",
"minutely_15": variables,
"hourly": variables,
"current": "precipitation,snowfall,weather_code,temperature_2m",
"precipitation_unit": "mm",
"timezone": "auto",
"forecast_days": 2, # cover the window even when "now" is late in the day
@@ -238,33 +371,41 @@ def fetch_precip_series(
if not now:
return None
common = {
"now": now,
"current_precip": current.get("precipitation"),
"current_code": current.get("weather_code"),
}
series: Optional[dict] = 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,
series = {
"times": m_times, "precip": m_precip,
"snow": m15.get("snowfall") or [],
"prob": m15.get("precipitation_probability") or [],
"temp": m15.get("temperature_2m") or [],
"codes": m15.get("weather_code") or [], "step": 15, **common,
}
else:
hourly = data.get("hourly", {}) or {}
h_times = hourly.get("time") or []
if not h_times:
return None
series = {
"times": h_times, "precip": hourly.get("precipitation") or [],
"snow": hourly.get("snowfall") or [],
"prob": hourly.get("precipitation_probability") or [],
"temp": hourly.get("temperature_2m") or [],
"codes": hourly.get("weather_code") or [], "step": 60, **common,
}
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,
}
if cache_ttl > 0:
if len(_SERIES_CACHE) >= _SERIES_CACHE_CAP:
_SERIES_CACHE.pop(next(iter(_SERIES_CACHE)))
_SERIES_CACHE[cache_key] = (time.time(), series)
return series
@dataclass
@@ -283,6 +424,8 @@ class NowcastResult:
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
amount_mm: Optional[float] = None # estimated liquid precip total (mm) over the episode within the window
snow_cm: Optional[float] = None # estimated snowfall total (cm) over the episode (snow depth, not liquid)
def _round5(minutes: float) -> int:
@@ -303,6 +446,8 @@ def analyze_precip_nowcast(
threshold: float = 0.1,
current_precip: Optional[float] = None,
current_code: Optional[int] = None,
snow: Optional[list[Optional[float]]] = None,
family: Optional[frozenset[str]] = None,
) -> Optional[NowcastResult]:
"""Pure nowcast analysis over a precipitation time series.
@@ -322,16 +467,29 @@ def analyze_precip_nowcast(
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.
snow: Optional snowfall per bucket, in cm (parallel to `times`). When a
snow episode is reported, snow_cm gives the depth estimate (snowfall
is the actual accumulation; precip is only its liquid equivalent).
family: Optional set of bucket names (e.g. {"snow"}); a bucket then counts
as precipitating only if its code maps into the family. None (default)
counts any precip — used to answer "is *snow* coming?" vs "rain?".
"""
if not times or not precip:
return None
def counts(amt: float, code: Optional[int]) -> bool:
"""Whether a bucket is precipitating for this query (>= threshold, and in
the requested precip family when one is given)."""
if amt < threshold:
return False
return family is None or precip_bucket_for_code(code) in family
n = min(len(times), len(precip))
try:
now = datetime.fromisoformat(now_iso)
except (TypeError, ValueError):
return None
parsed: list[tuple[datetime, float, Optional[int]]] = []
parsed: list[tuple[datetime, float, Optional[int], float]] = []
for i in range(n):
try:
t = datetime.fromisoformat(times[i])
@@ -340,64 +498,87 @@ def analyze_precip_nowcast(
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))
sf = snow[i] if (snow is not None and i < len(snow)) else None
sf = 0.0 if sf is None else float(sf)
parsed.append((t, amt, code, sf))
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):
for i, (t, _amt, _c, _sf) 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:]:
upcoming: list[tuple[float, float, Optional[int], float]] = [] # (mins, precip_mm, code, snow_cm)
for t, amt, code, sf 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))
upcoming.append((mins, amt, code, sf))
# "Raining now?" — prefer the API's instantaneous value, else the current bucket.
# "Precipitating now?" (of the requested family) — prefer the API's
# instantaneous value + current code, else the current bucket.
now_code = current_code if current_code is not None else (parsed[cur_idx][2] if cur_idx >= 0 else None)
if current_precip is not None:
raining_now = float(current_precip) >= threshold
raining_now = counts(float(current_precip), now_code)
elif cur_idx >= 0:
raining_now = parsed[cur_idx][1] >= threshold
raining_now = counts(parsed[cur_idx][1], parsed[cur_idx][2])
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)
# Accumulate the episode totals: liquid (mm) and snowfall (cm), the current
# bucket plus each upcoming bucket until precip drops below threshold.
total = max(0.0, parsed[cur_idx][1]) if cur_idx >= 0 else 0.0
total_snow = max(0.0, parsed[cur_idx][3]) if cur_idx >= 0 else 0.0
for mins, amt, code, sf in upcoming:
if not counts(amt, code):
return NowcastResult(
state="raining_stopping", minutes=_round5(mins), bucket=bucket,
amount_mm=total, snow_cm=total_snow,
)
total += amt
total_snow += sf
return NowcastResult(
state="raining_continuing", open_ended=True, bucket=bucket,
amount_mm=total, snow_cm=total_snow,
)
# Dry now: find the first upcoming precipitating bucket.
for idx, (mins, amt, code) in enumerate(upcoming):
if amt >= threshold:
# Dry now (of the requested family): find the first upcoming precip bucket.
for idx, (mins, amt, code, sf) in enumerate(upcoming):
if counts(amt, code):
bucket = precip_bucket_for_code(code) or "rain"
# How long does it last? Walk until it drops below threshold.
# How long does it last, and how much falls? Walk until it drops below
# threshold, summing liquid (mm) and snowfall (cm) for the estimate.
total = amt
total_snow = sf
end_mins: Optional[float] = None
for mins2, amt2, _c2 in upcoming[idx + 1:]:
if amt2 < threshold:
for mins2, amt2, code2, sf2 in upcoming[idx + 1:]:
if not counts(amt2, code2):
end_mins = mins2
break
total += amt2
total_snow += sf2
if end_mins is None:
return NowcastResult(
state="dry_incoming", minutes=_round5(mins), open_ended=True, bucket=bucket
state="dry_incoming", minutes=_round5(mins), open_ended=True,
bucket=bucket, amount_mm=total, snow_cm=total_snow,
)
return NowcastResult(
state="dry_incoming",
minutes=_round5(mins),
duration_minutes=_round5(end_mins - mins),
bucket=bucket,
amount_mm=total,
snow_cm=total_snow,
)
return NowcastResult(state="dry_clear")
@@ -463,15 +644,15 @@ 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"
keywords = ["rain", "nowcast", "snow"]
description = "Rain/snow nowcast: when precip starts or stops in the next ~2h, with amount"
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"]
short_description = "Rain/snow nowcast (when precip starts/stops) for a location"
usage = "rain|snow [city|zipcode|lat,lon]"
examples = ["rain", "snow", "rain seattle", "snow 98101", "rain 47.6,-122.3"]
parameters = [
{"name": "location", "description": "Optional: city, US ZIP, or lat,lon. Default: companion or bot location."}
]
@@ -490,6 +671,27 @@ class RainCommand(BaseCommand):
self.threshold_mm = self.get_config_value(
"Rain_Command", "precip_threshold_mm", fallback=0.1, value_type="float"
)
# Optional precip-amount estimate appended to the nowcast line, e.g.
# "(est 0.2 in)". Unit "in" (US default) or "mm"; show_amount toggles it.
self.show_amount = self.get_config_value(
"Rain_Command", "show_amount", fallback=True, value_type="bool"
)
self.amount_unit = self.bot.config.get(
"Rain_Command", "amount_unit", fallback="in"
).strip().lower()
# Show precip probability "(…, 70%)" and a borderline-temperature tag
# "34°F" (only when ~30-38°F, where rain/snow/ice is in doubt).
self.show_probability = self.get_config_value(
"Rain_Command", "show_probability", fallback=True, value_type="bool"
)
self.show_temp = self.get_config_value(
"Rain_Command", "show_temp", fallback=True, value_type="bool"
)
# Reuse a fetched series for this many seconds (shared with the proactive
# poll); 0 disables. Short so the nowcast's "now" stays fresh.
self.cache_ttl = self.get_config_value(
"Rain_Command", "cache_seconds", fallback=300, value_type="int"
)
# 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.
@@ -574,7 +776,7 @@ class RainCommand(BaseCommand):
city, suffix = self._reverse_geocode(lat, lon)
if not city:
return None
return f"{city}, {suffix}" if suffix else city
return join_location(city, suffix)
def _suffix_for_coords(self, lat: float, lon: float) -> Optional[str]:
"""US state abbreviation or country name for coordinates (enriches a known city)."""
@@ -599,7 +801,7 @@ class RainCommand(BaseCommand):
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
name = join_location(city, st)
except Exception as e:
self.logger.debug(f"Zippopotam ZIP lookup failed for {z}: {e}")
if name:
@@ -633,7 +835,7 @@ class RainCommand(BaseCommand):
# 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
label = join_location(self.default_city, suffix)
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)
@@ -686,7 +888,7 @@ class RainCommand(BaseCommand):
# — 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
label = join_location(typed_city, suffix)
return (lat, lon, label, None)
def _fetch_series(self, lat: float, lon: float) -> Optional[dict]:
@@ -696,6 +898,7 @@ class RainCommand(BaseCommand):
return fetch_precip_series(
session, lat, lon,
weather_model=self.weather_model, timeout=self.url_timeout, logger=self.logger,
cache_ttl=self.cache_ttl,
)
finally:
session.close()
@@ -711,13 +914,42 @@ class RainCommand(BaseCommand):
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."""
def _detail_suffix(self, result: NowcastResult, prob: Optional[int], temp_f: Optional[int]) -> str:
"""Trailing detail: ' (est 0.2 in, 70%) 34°F' — amount + probability in the
parens, plus a temperature tag only when borderline (~30-38°F)."""
parts: list[str] = []
if self.show_amount:
amt = format_amount_estimate(result.bucket, result.amount_mm, result.snow_cm, self.amount_unit)
if amt:
parts.append(f"est {amt}")
if self.show_probability and prob is not None:
parts.append(f"{prob}%")
paren = f" ({', '.join(parts)})" if parts else ""
temp = f" {temp_f}°F" if (self.show_temp and temp_f is not None and 30 <= temp_f <= 38) else ""
return paren + temp
def _format_result(
self, result: NowcastResult, location_label: str,
*, asked_word: Optional[str] = None, mismatch: bool = False,
prob: Optional[int] = None, temp_f: Optional[int] = None,
) -> str:
"""Render a NowcastResult into a single mesh-friendly line.
``asked_word`` is the precip the user asked for ("rain"/"snow"); when
``mismatch`` is set the result is the *other* type, rendered as
"No <asked>, but <actual> ..." so a !snow that finds rain still helps.
``prob``/``temp_f`` add a probability and borderline-temperature tag.
"""
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
"commands.rain.clear", precip=asked_word or "rain",
window=self._window_label(), location=location_label,
)
if mismatch and asked_word:
ptype = f"No {asked_word}, but {self._ptype(result.bucket).lower()}"
else:
ptype = self._ptype(result.bucket)
if result.state == "dry_incoming":
if result.open_ended or not result.duration_minutes:
extra = self.translate("commands.rain.duration_open")
@@ -725,29 +957,50 @@ class RainCommand(BaseCommand):
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,
)
emoji=emoji, ptype=ptype, minutes=result.minutes,
location=location_label, extra=extra,
) + self._detail_suffix(result, prob, temp_f)
if result.state == "raining_stopping":
return self.translate(
"commands.rain.stopping",
emoji=emoji,
ptype=self._ptype(result.bucket),
minutes=result.minutes,
location=location_label,
)
emoji=emoji, ptype=ptype, minutes=result.minutes, location=location_label,
) + self._detail_suffix(result, prob, temp_f)
# raining_continuing
return self.translate(
"commands.rain.continuing",
emoji=emoji,
ptype=self._ptype(result.bucket),
window=self._window_label(),
location=location_label,
emoji=emoji, ptype=ptype, window=self._window_label(), location=location_label,
) + self._detail_suffix(result, prob, temp_f)
def _format_changeover(self, rain_r: NowcastResult, snow_r: NowcastResult, location_label: str) -> str:
"""A rain<->snow transition line, e.g. '🌧️→🌨️ Rain now → snow in ~60min
for X' — whichever type comes first leads."""
def start(r: NowcastResult) -> int:
return 0 if r.state in ("raining_stopping", "raining_continuing") else (r.minutes or 0)
first_r, second_r = (rain_r, snow_r) if start(rain_r) <= start(snow_r) else (snow_r, rain_r)
when = self.translate("commands.rain.now") if start(first_r) == 0 else f"in ~{start(first_r)}min"
return self.translate(
"commands.rain.changeover",
from_emoji=_BUCKET_EMOJI.get(first_r.bucket or "rain", "🌧️"),
to_emoji=_BUCKET_EMOJI.get(second_r.bucket or "snow", "🌨️"),
first=self._ptype(first_r.bucket), when=when,
second=self._ptype(second_r.bucket).lower(),
minutes=start(second_r), location=location_label,
)
def get_help_text(self, message: Any = None) -> str:
"""Help tailored to the keyword asked about: 'help snow' talks snow
(depth), 'help rain'/'help nowcast' talk rain (amount). Falls back to
the rain variant when the queried word can't be determined."""
word = "rain"
content = (getattr(message, "content", "") or "").strip()
if content.startswith("!"):
content = content[1:].strip()
parts = content.split()
if len(parts) >= 2:
word = parts[1].lower()
key = "commands.rain.help_snow" if word == "snow" else "commands.rain.help_rain"
return self.translate(key)
async def execute(self, message: MeshMessage) -> bool:
content = message.content.strip()
if content.startswith("!"):
@@ -755,6 +1008,19 @@ class RainCommand(BaseCommand):
parts = content.split()
location: Optional[str] = " ".join(parts[1:]).strip() if len(parts) >= 2 else None
# Which keyword triggered us sets the precip we're asked about. !snow leads
# with snow, !rain with rain; !nowcast (or anything else) has no preference.
mode = parts[0].lower() if parts else "rain"
asked_word: Optional[str] = "snow" if mode == "snow" else (None if mode == "nowcast" else "rain")
# Bare country/US state (e.g. "france", "texas") -> default to its capital
# and append a heads-up, since one centroid point isn't representative.
region_note: Optional[str] = None
cap_query = region_capital_query(location)
if cap_query:
location = cap_query
region_note = REGION_DEFAULT_NOTE
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
@@ -788,21 +1054,40 @@ class RainCommand(BaseCommand):
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:
def run(fam: Optional[frozenset[str]]) -> Optional[NowcastResult]:
return 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"),
snow=series.get("snow"), family=fam,
)
# Analyze each precip family. Both present -> a changeover line; otherwise
# the asked-for type, falling back to the other with a "No <asked>, but …".
rain_r = run(RAIN_FAMILY)
snow_r = run(SNOW_FAMILY)
if rain_r is None or snow_r is None:
await self.send_response(message, self.translate("commands.rain.error_fetching"))
return True
rain_ok = rain_r.state != "dry_clear"
snow_ok = snow_r.state != "dry_clear"
label = location_label or f"{lat:.1f},{lon:.1f}"
response = self._format_result(result, location_label or f"{lat:.1f},{lon:.1f}")
if rain_ok and snow_ok:
response = self._format_changeover(rain_r, snow_r, label)
else:
if asked_word == "snow":
result, mismatch = (snow_r, False) if snow_ok else ((rain_r, True) if rain_ok else (snow_r, False))
elif asked_word == "rain":
result, mismatch = (rain_r, False) if rain_ok else ((snow_r, True) if snow_ok else (rain_r, False))
else: # nowcast: no type preference
result, mismatch = (rain_r if rain_ok else snow_r), False
prob, temp_f = episode_probability_temp(series, result)
response = self._format_result(
result, label, asked_word=asked_word, mismatch=mismatch, prob=prob, temp_f=temp_f,
)
if region_note:
response = f"{response} {region_note}"
max_len = self.get_max_message_length(message)
if len(response) > max_len:
response = response[: max_len - 3] + "..."
+151
View File
@@ -0,0 +1,151 @@
"""Capital-city lookup for bare country / US-state inputs to the rain command.
Self-contained — neither ``pycountry`` nor ``us`` is installed in production, so
this carries its own data. When a user gives only a region name (e.g. ``!rain
france``), the rain command resolves to that region's capital and appends a
short heads-up. Keyed by lowercased name; the value is ``(capital, region)``
used to build a geocoder query like ``"Paris, France"`` or ``"Austin, TX"``.
Lives outside ``modules/commands/`` so the plugin loader (which globs
``commands/*.py`` for command classes) never tries to load it as a command.
"""
from typing import Optional
# Short warning appended when a bare region defaults to its capital. ~71 bytes —
# fits the 160-byte channel budget alongside any nowcast line (verified).
REGION_DEFAULT_NOTE = "⚠️ no city given — showing capital; try a city for detail"
# US state name -> (capital, abbreviation). Keyed by the full state name only;
# bare 2-letter abbreviations are too ambiguous ("in", "or", "la", "me") to map.
US_STATE_CAPITALS = {
"alabama": ("Montgomery", "AL"), "alaska": ("Juneau", "AK"),
"arizona": ("Phoenix", "AZ"), "arkansas": ("Little Rock", "AR"),
"california": ("Sacramento", "CA"), "colorado": ("Denver", "CO"),
"connecticut": ("Hartford", "CT"), "delaware": ("Dover", "DE"),
"florida": ("Tallahassee", "FL"), "georgia": ("Atlanta", "GA"),
"hawaii": ("Honolulu", "HI"), "idaho": ("Boise", "ID"),
"illinois": ("Springfield", "IL"), "indiana": ("Indianapolis", "IN"),
"iowa": ("Des Moines", "IA"), "kansas": ("Topeka", "KS"),
"kentucky": ("Frankfort", "KY"), "louisiana": ("Baton Rouge", "LA"),
"maine": ("Augusta", "ME"), "maryland": ("Annapolis", "MD"),
"massachusetts": ("Boston", "MA"), "michigan": ("Lansing", "MI"),
"minnesota": ("Saint Paul", "MN"), "mississippi": ("Jackson", "MS"),
"missouri": ("Jefferson City", "MO"), "montana": ("Helena", "MT"),
"nebraska": ("Lincoln", "NE"), "nevada": ("Carson City", "NV"),
"new hampshire": ("Concord", "NH"), "new jersey": ("Trenton", "NJ"),
"new mexico": ("Santa Fe", "NM"), "new york": ("Albany", "NY"),
"north carolina": ("Raleigh", "NC"), "north dakota": ("Bismarck", "ND"),
"ohio": ("Columbus", "OH"), "oklahoma": ("Oklahoma City", "OK"),
"oregon": ("Salem", "OR"), "pennsylvania": ("Harrisburg", "PA"),
"rhode island": ("Providence", "RI"), "south carolina": ("Columbia", "SC"),
"south dakota": ("Pierre", "SD"), "tennessee": ("Nashville", "TN"),
"texas": ("Austin", "TX"), "utah": ("Salt Lake City", "UT"),
"vermont": ("Montpelier", "VT"), "virginia": ("Richmond", "VA"),
"washington": ("Olympia", "WA"), "west virginia": ("Charleston", "WV"),
"wisconsin": ("Madison", "WI"), "wyoming": ("Cheyenne", "WY"),
}
# Bare state names that almost always mean the city, not the state. Nominatim
# resolves these to the city (NYC, Washington DC) on its own, so let them.
STATE_AS_CITY = {"new york", "washington"}
# Country / common-alias -> (capital, country name for the geocoder query).
WORLD_CAPITALS = {
# North & Central America
"united states": ("Washington", "United States"), "usa": ("Washington", "United States"),
"america": ("Washington", "United States"), "canada": ("Ottawa", "Canada"),
"mexico": ("Mexico City", "Mexico"), "guatemala": ("Guatemala City", "Guatemala"),
"cuba": ("Havana", "Cuba"), "jamaica": ("Kingston", "Jamaica"),
"haiti": ("Port-au-Prince", "Haiti"), "dominican republic": ("Santo Domingo", "Dominican Republic"),
"panama": ("Panama City", "Panama"), "costa rica": ("San Jose", "Costa Rica"),
"honduras": ("Tegucigalpa", "Honduras"), "el salvador": ("San Salvador", "El Salvador"),
"nicaragua": ("Managua", "Nicaragua"), "belize": ("Belmopan", "Belize"),
# South America
"colombia": ("Bogota", "Colombia"), "venezuela": ("Caracas", "Venezuela"),
"ecuador": ("Quito", "Ecuador"), "peru": ("Lima", "Peru"),
"brazil": ("Brasilia", "Brazil"), "bolivia": ("La Paz", "Bolivia"),
"chile": ("Santiago", "Chile"), "argentina": ("Buenos Aires", "Argentina"),
"uruguay": ("Montevideo", "Uruguay"), "paraguay": ("Asuncion", "Paraguay"),
# Europe
"france": ("Paris", "France"), "germany": ("Berlin", "Germany"),
"italy": ("Rome", "Italy"), "spain": ("Madrid", "Spain"),
"portugal": ("Lisbon", "Portugal"), "united kingdom": ("London", "United Kingdom"),
"uk": ("London", "United Kingdom"), "britain": ("London", "United Kingdom"),
"great britain": ("London", "United Kingdom"), "england": ("London", "United Kingdom"),
"scotland": ("Edinburgh", "United Kingdom"), "wales": ("Cardiff", "United Kingdom"),
"ireland": ("Dublin", "Ireland"), "netherlands": ("Amsterdam", "Netherlands"),
"holland": ("Amsterdam", "Netherlands"), "belgium": ("Brussels", "Belgium"),
"luxembourg": ("Luxembourg", "Luxembourg"), "switzerland": ("Bern", "Switzerland"),
"austria": ("Vienna", "Austria"), "poland": ("Warsaw", "Poland"),
"czechia": ("Prague", "Czechia"), "czech republic": ("Prague", "Czechia"),
"slovakia": ("Bratislava", "Slovakia"), "hungary": ("Budapest", "Hungary"),
"romania": ("Bucharest", "Romania"), "bulgaria": ("Sofia", "Bulgaria"),
"greece": ("Athens", "Greece"), "sweden": ("Stockholm", "Sweden"),
"norway": ("Oslo", "Norway"), "denmark": ("Copenhagen", "Denmark"),
"finland": ("Helsinki", "Finland"), "iceland": ("Reykjavik", "Iceland"),
"russia": ("Moscow", "Russia"), "ukraine": ("Kyiv", "Ukraine"),
"belarus": ("Minsk", "Belarus"), "croatia": ("Zagreb", "Croatia"),
"serbia": ("Belgrade", "Serbia"), "slovenia": ("Ljubljana", "Slovenia"),
"albania": ("Tirana", "Albania"), "estonia": ("Tallinn", "Estonia"),
"latvia": ("Riga", "Latvia"), "lithuania": ("Vilnius", "Lithuania"),
"monaco": ("Monaco", "Monaco"), "malta": ("Valletta", "Malta"),
"cyprus": ("Nicosia", "Cyprus"),
# Middle East & Central Asia
"turkey": ("Ankara", "Turkey"), "turkiye": ("Ankara", "Turkey"),
"israel": ("Jerusalem", "Israel"), "jordan": ("Amman", "Jordan"),
"lebanon": ("Beirut", "Lebanon"), "syria": ("Damascus", "Syria"),
"iraq": ("Baghdad", "Iraq"), "iran": ("Tehran", "Iran"),
"saudi arabia": ("Riyadh", "Saudi Arabia"), "uae": ("Abu Dhabi", "United Arab Emirates"),
"united arab emirates": ("Abu Dhabi", "United Arab Emirates"), "qatar": ("Doha", "Qatar"),
"kuwait": ("Kuwait City", "Kuwait"), "oman": ("Muscat", "Oman"),
"yemen": ("Sanaa", "Yemen"), "kazakhstan": ("Astana", "Kazakhstan"),
"uzbekistan": ("Tashkent", "Uzbekistan"), "afghanistan": ("Kabul", "Afghanistan"),
# South & East Asia
"china": ("Beijing", "China"), "japan": ("Tokyo", "Japan"),
"south korea": ("Seoul", "South Korea"), "korea": ("Seoul", "South Korea"),
"north korea": ("Pyongyang", "North Korea"), "india": ("New Delhi", "India"),
"pakistan": ("Islamabad", "Pakistan"), "bangladesh": ("Dhaka", "Bangladesh"),
"sri lanka": ("Colombo", "Sri Lanka"), "nepal": ("Kathmandu", "Nepal"),
"thailand": ("Bangkok", "Thailand"), "vietnam": ("Hanoi", "Vietnam"),
"cambodia": ("Phnom Penh", "Cambodia"), "laos": ("Vientiane", "Laos"),
"myanmar": ("Naypyidaw", "Myanmar"), "burma": ("Naypyidaw", "Myanmar"),
"malaysia": ("Kuala Lumpur", "Malaysia"), "singapore": ("Singapore", "Singapore"),
"indonesia": ("Jakarta", "Indonesia"), "philippines": ("Manila", "Philippines"),
"mongolia": ("Ulaanbaatar", "Mongolia"), "taiwan": ("Taipei", "Taiwan"),
# Africa
"egypt": ("Cairo", "Egypt"), "morocco": ("Rabat", "Morocco"),
"algeria": ("Algiers", "Algeria"), "tunisia": ("Tunis", "Tunisia"),
"libya": ("Tripoli", "Libya"), "nigeria": ("Abuja", "Nigeria"),
"ghana": ("Accra", "Ghana"), "kenya": ("Nairobi", "Kenya"),
"ethiopia": ("Addis Ababa", "Ethiopia"), "tanzania": ("Dodoma", "Tanzania"),
"uganda": ("Kampala", "Uganda"), "south africa": ("Pretoria", "South Africa"),
"zimbabwe": ("Harare", "Zimbabwe"), "zambia": ("Lusaka", "Zambia"),
"angola": ("Luanda", "Angola"), "senegal": ("Dakar", "Senegal"),
"cameroon": ("Yaounde", "Cameroon"), "sudan": ("Khartoum", "Sudan"),
# Oceania
"australia": ("Canberra", "Australia"), "new zealand": ("Wellington", "New Zealand"),
"fiji": ("Suva", "Fiji"), "papua new guinea": ("Port Moresby", "Papua New Guinea"),
}
def region_capital_query(location: Optional[str]) -> Optional[str]:
"""Return a ``"Capital, Region"`` geocoder query for a bare country or US
state, else ``None``.
Case-insensitive and whitespace-normalized. A comma in the input means the
user already qualified a city ("Paris, France"), so it's not a bare region.
City-dominant state names (New York, Washington) are excluded so they keep
resolving to the city.
"""
if not location:
return None
key = " ".join(location.strip().lower().split())
if not key or "," in location:
return None
if key in US_STATE_CAPITALS and key not in STATE_AS_CITY:
capital, abbr = US_STATE_CAPITALS[key]
return f"{capital}, {abbr}"
if key in WORLD_CAPITALS:
capital, country = WORLD_CAPITALS[key]
return f"{capital}, {country}"
return None
+37 -5
View File
@@ -33,7 +33,10 @@ import contextlib
from ..commands.rain_command import (
analyze_precip_nowcast,
decide_rain_notification,
episode_probability_temp,
fetch_precip_series,
format_amount_estimate,
join_location,
precip_descriptor,
reverse_geocode_region,
)
@@ -106,6 +109,13 @@ class WeatherService(BaseServicePlugin):
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)
# Optional precip-amount estimate in the heads-up, e.g. "(est 0.2 in)".
self.rain_nowcast_show_amount = self.bot.config.getboolean('Weather_Service', 'rain_nowcast_show_amount', fallback=True)
self.rain_nowcast_amount_unit = self.bot.config.get('Weather_Service', 'rain_nowcast_amount_unit', fallback='in').strip().lower()
# Only push an "incoming" alert when precip is at least this likely (%), to
# cut false alarms; reuse a fetched series for this many seconds.
self.rain_nowcast_min_probability = self.bot.config.getint('Weather_Service', 'rain_nowcast_min_probability', fallback=50)
self.rain_nowcast_cache_seconds = self.bot.config.getint('Weather_Service', 'rain_nowcast_cache_seconds', fallback=300)
# Track seen alerts to avoid duplicates
self.seen_alert_ids: set[str] = set()
@@ -835,6 +845,7 @@ class WeatherService(BaseServicePlugin):
weather_model=self.weather_model or "",
timeout=10,
logger=self.logger,
cache_ttl=self.rain_nowcast_cache_seconds,
),
)
if not series:
@@ -847,10 +858,17 @@ class WeatherService(BaseServicePlugin):
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"),
snow=series.get("snow"),
)
if result is None:
return
prob, temp_f = episode_probability_temp(series, result)
# Probability gate: skip a low-confidence "incoming" alert, and leave
# the announced flag unset so it can still fire if confidence rises.
if result.state == "dry_incoming" and prob is not None and prob < self.rain_nowcast_min_probability:
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)
@@ -868,7 +886,7 @@ class WeatherService(BaseServicePlugin):
if kind is None:
return
message = await self._format_rain_nowcast(result, kind)
message = await self._format_rain_nowcast(result, kind, prob, temp_f)
await self.bot.command_manager.send_channel_message(
self.rain_channel,
message,
@@ -883,10 +901,13 @@ class WeatherService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error checking rain nowcast: {e}")
async def _format_rain_nowcast(self, result: Any, kind: str) -> str:
async def _format_rain_nowcast(
self, result: Any, kind: str, prob: Optional[int] = None, temp_f: Optional[int] = None
) -> str:
"""Build the proactive heads-up line (English, mesh-friendly).
kind is "starting" (rain incoming) or "ending" (rain about to stop).
prob/temp_f add a probability and a borderline-temperature tag.
"""
emoji, ptype = precip_descriptor(result.bucket)
@@ -900,15 +921,26 @@ class WeatherService(BaseServicePlugin):
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 ""
self._cached_rain_location = join_location(city, suffix)
location = f" near {self._cached_rain_location}" if self._cached_rain_location else ""
parts = []
if self.rain_nowcast_show_amount:
amt = format_amount_estimate(
result.bucket, result.amount_mm, result.snow_cm, self.rain_nowcast_amount_unit
)
if amt:
parts.append(f"est {amt}")
if prob is not None:
parts.append(f"{prob}%")
est = f" ({', '.join(parts)})" if parts else ""
temp = f" {temp_f}°F" if (temp_f is not None and 30 <= temp_f <= 38) else ""
if kind == "ending":
return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{location}"
return f"{emoji} Heads up — {ptype} ending in ~{result.minutes}min{est}{temp}{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}"
return f"{emoji} Heads up — {ptype} starting in ~{result.minutes}min{est}{steady}{temp}{location}"
async def _connect_blitzortung_mqtt(self) -> None:
"""Connect to Blitzortung MQTT broker and subscribe to lightning data.
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Shared scaffolding for the rain/snow command tests (not a test module).
The leading underscore keeps pytest from collecting this file. It builds a
RainCommand wired to a real ConfigParser + real i18n Translator, captures the
reply at bot.command_manager.send_response, and (optionally) stubs the two
network seams so renders are deterministic. Imported by test_rain_command_e2e
(mocked weather) and test_rain_live_smoke (real Open-Meteo).
"""
import asyncio
import configparser
import re
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from modules.commands.rain_command import RainCommand
from modules.i18n import Translator
from modules.models import MeshMessage
REPO_ROOT = Path(__file__).resolve().parents[2]
TRANSLATIONS = str(REPO_ROOT / "translations")
NOW = "2026-06-03T14:00"
DEFAULT_LABEL = "Nashville, TN"
DEFAULT_COORDS = (36.1627, -86.7816)
def make_series(
*, n=9, step=15, now=NOW,
precip=None, codes=None, snow=None, prob=None, temp=None,
current_precip=0.0, current_code=0,
):
"""Build the dict shape fetch_precip_series() returns. Lists shorter than n
are zero/None-padded; temp defaults to a mild 18 C (no borderline tag)."""
base = datetime.fromisoformat(now)
times = [(base + timedelta(minutes=step * i)).isoformat(timespec="minutes") for i in range(n)]
def pad(seq, fill):
seq = list(seq if seq is not None else [])
return (seq + [fill] * (n - len(seq)))[:n]
return {
"times": times,
"precip": pad(precip, 0.0),
"codes": pad(codes, 0),
"snow": pad(snow, 0.0),
"prob": pad(prob, 0),
"temp": pad(temp, 18.0),
"now": now,
"current_precip": current_precip,
"current_code": current_code,
"step": step,
}
def make_bot(*, bot_name="WeatherBot-V3", rain_overrides=None):
"""A minimal bot with a real config + real translator and a capturing
command_manager.send_response. Returns (bot, captured_responses_list).
A SimpleNamespace (not a Mock) is deliberate: get_max_message_length checks
hasattr(bot, 'meshcore'), and a Mock would answer True to everything.
"""
cfg = configparser.ConfigParser()
cfg.add_section("Bot")
cfg.set("Bot", "bot_name", bot_name)
cfg.add_section("Channels")
cfg.set("Channels", "monitor_channels", "general")
cfg.set("Channels", "respond_to_dms", "true")
cfg.add_section("Rain_Command")
cfg.set("Rain_Command", "enabled", "true")
for key, val in (rain_overrides or {}).items():
cfg.set("Rain_Command", key, val)
captured: list[str] = []
async def _send_response(message, content, **kwargs):
captured.append(content)
return True
async def _send_response_chunked(message, chunks, **kwargs):
captured.extend(chunks)
return True
command_manager = SimpleNamespace(
send_response=_send_response,
send_response_chunked=_send_response_chunked,
monitor_channels=["general"],
)
bot = SimpleNamespace(
logger=Mock(),
config=cfg,
translator=Translator(language="en", translation_path=TRANSLATIONS),
command_manager=command_manager,
)
return bot, captured
def build_cmd(
series=None, *, coords=DEFAULT_COORDS, label=DEFAULT_LABEL,
rain_overrides=None, bot_name="WeatherBot-V3",
):
"""RainCommand wired to a fixed resolved location. When `series` is given,
the Open-Meteo fetch is stubbed to it; when None, the real fetch runs
(used by the live smoke). Geocoding is always bypassed for determinism."""
bot, captured = make_bot(bot_name=bot_name, rain_overrides=rain_overrides)
cmd = RainCommand(bot)
if series is not None:
cmd._fetch_series = lambda lat, lon: series
cmd._resolve_location = lambda message, location: (coords[0], coords[1], location or label, None)
return cmd, captured
def render(cmd, captured, content, *, is_dm=False):
"""Run execute() once and return the single captured reply, asserting it
fits the real channel/DM byte budget."""
msg = MeshMessage(
content=content,
channel=None if is_dm else "general",
is_dm=is_dm,
sender_id="U1",
)
ok = asyncio.run(cmd.execute(msg))
assert ok is True
assert len(captured) == 1, f"expected one reply, got {captured!r}"
resp = captured[-1]
budget = cmd.get_max_message_length(msg)
assert len(resp.encode("utf-8")) <= budget, f"{len(resp.encode('utf-8'))}B > {budget}B: {resp!r}"
return resp
def assert_render(resp, pattern):
assert re.fullmatch(pattern, resp), f"\nGOT: {resp!r}\nEXPECTED: /{pattern}/"
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""End-to-end render tests for the rain/snow command (mocked weather).
Unlike test_rain_nowcast.py (which unit-tests the pure helpers), this drives the
real RainCommand.execute() coroutine and asserts on the *exact string a user
gets back* on the mesh. Scaffolding (real config, real i18n translator, a
capturing send_response, synthetic Open-Meteo series) lives in _rain_harness.py.
Only the two network seams are stubbed: the Open-Meteo fetch (synthetic series)
and location resolution (fixed). Everything in between mode parsing,
region-capital defaulting, the rain/snow family two-pass, the mismatch
("No snow, but rain ...") and changeover lines, amount/probability/temperature
suffixes, the region heads-up, and the byte-budget truncation runs for real.
This is the regression gate for "do the commands still come back right".
.venv/bin/python -m pytest tests/unit/test_rain_command_e2e.py -o addopts="" -q
# see every rendered reply:
.venv/bin/python -m pytest tests/unit/test_rain_command_e2e.py -o addopts="" -s -q -k gallery
"""
from modules.models import MeshMessage
from modules.region_capitals import REGION_DEFAULT_NOTE
from tests.unit._rain_harness import (
DEFAULT_LABEL,
assert_render,
build_cmd,
make_series,
render,
)
# Reusable code/precip presets (15-min buckets).
_RAIN_INCOMING = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 20, 80, 80, 70])
_SNOW_INCOMING = dict(
precip=[0, 0, 0.3, 0.3, 0.3], codes=[0, 0, 71, 71, 71],
snow=[0, 0, 2.0, 2.0, 2.0], prob=[10, 20, 80, 80, 70],
)
_RAINING_NOW = dict(precip=[0.5] * 9, codes=[61] * 9, prob=[90] * 9, current_precip=0.5, current_code=61)
# --- dry / clear ------------------------------------------------------------
def test_rain_dry_clear():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!rain") == f"☀️ No rain expected in next 2h for {DEFAULT_LABEL}"
def test_snow_dry_clear_says_snow():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!snow") == f"☀️ No snow expected in next 2h for {DEFAULT_LABEL}"
def test_nowcast_dry_clear_defaults_to_rain_word():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!nowcast") == f"☀️ No rain expected in next 2h for {DEFAULT_LABEL}"
# --- incoming precipitation -------------------------------------------------
def test_rain_incoming_has_amount_and_probability():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🌧️ Rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in, 80%\)",
)
def test_nowcast_incoming_is_plain_rain_no_mismatch_wording():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!nowcast")
assert "No " not in resp
assert resp.startswith("🌧️ Rain starting in ~30min for Nashville, TN")
def test_snow_incoming_shows_depth():
cmd, cap = build_cmd(make_series(**_SNOW_INCOMING))
resp = render(cmd, cap, "!snow")
assert_render(
resp,
r"🌨️ Snow starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in snow, 80%\)",
)
def test_raining_now_continuing():
cmd, cap = build_cmd(make_series(**_RAINING_NOW))
resp = render(cmd, cap, "!rain")
assert_render(resp, r"🌧️ Rain steady for 2h\+ in Nashville, TN \(est [\d.]+ in, 90%\)")
# --- cross-type mismatch ----------------------------------------------------
def test_snow_command_when_raining_says_no_snow_but_rain():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!snow")
assert_render(
resp,
r"🌧️ No snow, but rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in, 80%\)",
)
def test_rain_command_when_snowing_says_no_rain_but_snow():
cmd, cap = build_cmd(make_series(**_SNOW_INCOMING))
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🌨️ No rain, but snow starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in snow, 80%\)",
)
# --- changeover (both families in the window) -------------------------------
def test_rain_now_then_snow_changeover():
series = make_series(
precip=[0.5] * 9,
codes=[61, 61, 61, 61, 71, 71, 71, 71, 71],
snow=[0, 0, 0, 0, 2.0, 2.0, 2.0, 2.0, 2.0],
prob=[90] * 9,
current_precip=0.5,
current_code=61,
)
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert_render(resp, r"🌧️→🌨️ Rain now → snow in ~\d+min for Nashville, TN")
# --- freezing rain tagged as ice --------------------------------------------
def test_freezing_rain_estimate_tagged_in_ice():
series = make_series(precip=[0, 0, 0.4, 0.4, 0.4], codes=[0, 0, 66, 66, 66], prob=[10, 20, 70, 70, 60])
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🧊 Freezing rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in ice, 70%\)",
)
# --- borderline temperature tag ---------------------------------------------
def test_borderline_temp_tag_appended_near_freezing():
# ~1.7 C == ~35 F at the start bucket -> tag shown.
series = make_series(**{**_RAIN_INCOMING, "temp": [10, 10, 1.7, 1.7, 1.7]})
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert resp.endswith("35°F"), resp
assert_render(resp, r".+ \(est [\d.]+ in, 80%\) 35°F")
def test_no_temp_tag_when_mild():
# 10 C == 50 F -> outside the 30-38 F band -> no tag.
series = make_series(**{**_RAIN_INCOMING, "temp": [10] * 9})
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert "°F" not in resp
assert resp.endswith("%)"), resp
def test_show_temp_disabled_via_config():
series = make_series(**{**_RAIN_INCOMING, "temp": [1.7] * 9})
cmd, cap = build_cmd(series, rain_overrides={"show_temp": "false"})
resp = render(cmd, cap, "!rain")
assert "°F" not in resp
# --- region-capital defaulting + heads-up -----------------------------------
def test_bare_country_defaults_to_capital_with_note():
cmd, cap = build_cmd(make_series())
resp = render(cmd, cap, "!rain france")
assert "Paris, France" in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
def test_spain_does_not_double_and_gets_capital():
cmd, cap = build_cmd(make_series())
resp = render(cmd, cap, "!rain spain")
assert "Madrid, Spain" in resp
assert "Spain, Spain" not in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
def test_us_state_defaults_to_capital_with_note():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain texas")
assert "Austin, TX" in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
# --- config toggles ---------------------------------------------------------
def test_show_amount_disabled_hides_estimate():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING), rain_overrides={"show_amount": "false"})
resp = render(cmd, cap, "!rain")
assert "est" not in resp
assert_render(resp, r"🌧️ Rain starting in ~30min for Nashville, TN \(~\d+min\) \(80%\)")
def test_amount_unit_mm():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING), rain_overrides={"amount_unit": "mm"})
resp = render(cmd, cap, "!rain")
assert "mm" in resp
assert " in," not in resp
# --- DM path ----------------------------------------------------------------
def test_dm_renders_and_fits_dm_budget():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain", is_dm=True)
assert resp.startswith("🌧️ Rain starting in ~30min for Nashville, TN")
# --- keyword-aware help -----------------------------------------------------
def test_help_rain_vs_snow_keyword_aware():
cmd, _ = build_cmd(make_series())
rain_help = cmd.get_help_text(MeshMessage(content="help rain", channel="general", sender_id="U1"))
snow_help = cmd.get_help_text(MeshMessage(content="help snow", channel="general", sender_id="U1"))
bare_help = cmd.get_help_text(MeshMessage(content="help", channel="general", sender_id="U1"))
assert "rain" in rain_help.lower() and "amount" in rain_help.lower()
assert "snow" in snow_help.lower() and "depth" in snow_help.lower()
assert rain_help != snow_help
assert bare_help == rain_help # defaults to rain when unspecified
# --- gallery (visual): print one of each, run with -s to eyeball ------------
def test_gallery_prints_representative_replies():
cases = [
("!rain (dry)", make_series()),
("!rain (incoming)", make_series(**_RAIN_INCOMING)),
("!rain (raining now)", make_series(**_RAINING_NOW)),
("!snow (incoming)", make_series(**_SNOW_INCOMING)),
("!rain (freezing)", make_series(precip=[0, 0, 0.4, 0.4, 0.4], codes=[0, 0, 66, 66, 66], prob=[0, 0, 70, 70, 60])),
]
lines = []
for label, series in cases:
cmd, cap = build_cmd(series)
word = label.split()[0].lstrip("!")
resp = render(cmd, cap, f"!{word}")
lines.append(f"{label:24s} -> {resp} [{len(resp.encode('utf-8'))}B]")
# snow-but-raining mismatch + region capital, for completeness
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
lines.append(f"{'!snow (but raining)':24s} -> {render(cmd, cap, '!snow')}")
cmd, cap = build_cmd(make_series())
lines.append(f"{'!rain france (capital)':24s} -> {render(cmd, cap, '!rain france')}")
print("\n--- rain/snow command gallery ---")
for ln in lines:
print(ln)
assert all("None" not in ln for ln in lines) # no stray Nones leaked into output
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Live smoke test against the real Open-Meteo API.
Opt-in (network): SKIPPED unless RAIN_LIVE_SMOKE is set, so CI and the normal
offline suite never depend on it. The mocked suites can't catch upstream schema
drift (Open-Meteo renaming/dropping a field, or no longer serving 15-min
probability/temperature); this one can, and it exercises the real fetch end to
end through the command. It does NOT geocode (Nominatim) coordinates are fixed
and only the label is stubbed.
RAIN_LIVE_SMOKE=1 .venv/bin/python -m pytest tests/unit/test_rain_live_smoke.py -o addopts="" -s -q
"""
import os
import pytest
import requests
from modules.commands.rain_command import fetch_precip_series
from tests.unit._rain_harness import build_cmd, render
pytestmark = pytest.mark.skipif(
os.environ.get("RAIN_LIVE_SMOKE") in (None, "", "0"),
reason="live network test; set RAIN_LIVE_SMOKE=1 to run",
)
# (label, lat, lon) — a geographic spread so at least one usually has weather.
PLACES = [
("Nashville, TN", 36.1627, -86.7816),
("London, UK", 51.5072, -0.1276),
("Seattle, WA", 47.6062, -122.3321),
]
# Keys the analysis + formatting rely on; prob/temp are the newer dependencies.
REQUIRED_KEYS = {"times", "precip", "snow", "prob", "temp", "codes", "now", "step"}
def test_live_fetch_schema_is_intact():
"""The real API still returns every field we consume, at aligned lengths,
with 15-min probability + temperature populated (the features added last)."""
session = requests.Session()
try:
for label, lat, lon in PLACES:
series = fetch_precip_series(session, lat, lon, timeout=15)
assert series is not None, f"{label}: no series returned"
missing = REQUIRED_KEYS - series.keys()
assert not missing, f"{label}: missing keys {missing}"
n = len(series["times"])
assert n > 0, f"{label}: empty time series"
assert series["step"] in (15, 60)
for k in ("precip", "codes"):
assert len(series[k]) == n, f"{label}: {k} length {len(series[k])} != times {n}"
prob_ok = sum(p is not None for p in series["prob"])
temp_ok = sum(t is not None for t in series["temp"])
assert prob_ok > 0, f"{label}: probability series all None — upstream drift?"
assert temp_ok > 0, f"{label}: temperature series all None — upstream drift?"
print(
f"\n{label:14s} step={series['step']}min n={n} "
f"prob_nonnull={prob_ok} temp_nonnull={temp_ok} now={series['now']}"
)
finally:
session.close()
def test_live_command_renders_for_real_places():
"""Drive execute() with a real fetch for each place and print the real reply.
Asserts only invariants (non-empty, fits budget, no stray 'None')."""
print("\n--- live !rain / !snow ---")
for label, lat, lon in PLACES:
for word in ("rain", "snow"):
# series=None -> real Open-Meteo fetch; location is fixed (no geocoding).
cmd, cap = build_cmd(series=None, coords=(lat, lon), label=label)
resp = render(cmd, cap, f"!{word}")
assert resp, f"{label}: empty !{word} reply"
assert "None" not in resp, f"{label}: stray None in !{word} reply: {resp!r}"
print(f"!{word:4s} {label:14s} -> {resp}")
+254
View File
@@ -7,15 +7,23 @@ explicitly, matching how the command derives it from the API's current.time.
"""
from modules.commands.rain_command import (
RAIN_FAMILY,
SNOW_FAMILY,
NowcastResult,
_round5, # noqa: PLC2701 (testing internal helper)
analyze_precip_nowcast,
city_display_name,
decide_rain_notification,
episode_probability_temp,
format_amount_estimate,
format_precip_amount,
format_snow_amount,
join_location,
precip_bucket_for_code,
precip_descriptor,
titlecase_location,
)
from modules.region_capitals import REGION_DEFAULT_NOTE, region_capital_query
NOW = "2026-06-03T14:00"
@@ -348,3 +356,249 @@ def test_decide_full_episode_sequence():
clock += 15 * 60 # advance 15 min between polls
assert kinds == [None, "starting", None, None, "ending", None, None]
# --- amount estimate (amount_mm on the result) ------------------------------
def test_amount_dry_incoming_episode_sum():
# Rain 14:30 + 14:45 (0.5 each), dry after -> episode total 1.0 mm.
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 abs(r.amount_mm - 1.0) < 1e-9
def test_amount_dry_incoming_open_ended_sums_to_window_edge():
# Rain from 14:30 through 16:00 (+120, the window edge): 7 buckets x 0.5.
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.open_ended is True
assert abs(r.amount_mm - 3.5) < 1e-9
def test_amount_raining_stopping_includes_current_bucket():
# Raining now (0.5) + 14:15 (0.5), clears at 14:30 -> 1.0 mm remaining.
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 abs(r.amount_mm - 1.0) < 1e-9
def test_amount_raining_continuing_sums_current_plus_window():
# Steady 0.5 across the current bucket + 8 upcoming -> 4.5 mm over 2h.
precip = [0.5] * 9
r = analyze_precip_nowcast(TIMES_15, precip, _codes(9, 65), NOW, window_minutes=120)
assert r.state == "raining_continuing"
assert abs(r.amount_mm - 4.5) < 1e-9
def test_amount_dry_clear_is_none():
r = analyze_precip_nowcast(TIMES_15, [0.0] * 9, _codes(9, 0), NOW, window_minutes=120)
assert r.state == "dry_clear"
assert r.amount_mm is None
# --- format_precip_amount ---------------------------------------------------
def test_format_amount_inches_trims_zeros():
assert format_precip_amount(5.08, "in") == "0.2 in" # user's example
assert format_precip_amount(25.4, "in") == "1 in" # exact inch
assert format_precip_amount(4.5, "in") == "0.18 in"
def test_format_amount_inches_trace_and_none():
assert format_precip_amount(0.2, "in") == "<0.01 in" # 0.008 in rounds away
assert format_precip_amount(0.0, "in") is None
assert format_precip_amount(None, "in") is None
assert format_precip_amount(-1.0, "in") is None
def test_format_amount_mm_unit():
assert format_precip_amount(5.0, "mm") == "5.0 mm"
assert format_precip_amount(12.3, "mm") == "12.3 mm"
assert format_precip_amount(0.05, "mm") == "<0.1 mm"
# --- snowfall amount (depth, not liquid equivalent) -------------------------
def test_amount_snow_uses_snowfall_series():
# Snow incoming 14:30-14:45: 3.5 cm/bucket snow vs only 0.5 mm/bucket liquid.
precip = [0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]
snow = [0.0, 0.0, 3.5, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0]
codes = [0, 0, 73, 73, 0, 0, 0, 0, 0]
r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, window_minutes=120, snow=snow)
assert r.state == "dry_incoming"
assert r.bucket == "snow"
assert abs(r.amount_mm - 1.0) < 1e-9 # liquid equivalent
assert abs(r.snow_cm - 7.0) < 1e-9 # actual snow depth (the useful number)
def test_amount_snow_none_series_defaults_zero():
# No snow series -> snow_cm sums to 0; the rain path is unaffected.
precip = [0.0, 0.0, 0.5, 0.5, 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_incoming"
assert r.bucket == "rain"
assert r.snow_cm == 0.0
def test_format_snow_amount():
assert format_snow_amount(7.0, "in") == "2.8 in snow" # 7 cm -> ~2.8"
assert format_snow_amount(2.54, "in") == "1 in snow" # exact inch
assert format_snow_amount(0.1, "in") == "<0.1 in snow"
assert format_snow_amount(0.0, "in") is None
assert format_snow_amount(None, "in") is None
assert format_snow_amount(12.5, "mm") == "12.5 cm snow" # metric shows cm
def test_episode_probability_temp_dry_incoming():
# Rain starts at 14:30 (+30min) -> prob/temp read at that bucket.
s = {"times": TIMES_15, "now": NOW,
"prob": [10, 20, 80, 80, 30, 0, 0, 0, 0],
"temp": [20, 20, 1.0, 1.0, 5, 5, 5, 5, 5]} # 1.0C -> 34F
r = NowcastResult(state="dry_incoming", minutes=30, bucket="rain")
assert episode_probability_temp(s, r) == (80, 34)
def test_episode_probability_temp_raining_now():
s = {"times": TIMES_15, "now": NOW,
"prob": [90, 90, 0, 0, 0, 0, 0, 0, 0],
"temp": [0.0, 0.0, 0, 0, 0, 0, 0, 0, 0]} # 0C -> 32F at "now" bucket
r = NowcastResult(state="raining_continuing", bucket="rain")
assert episode_probability_temp(s, r) == (90, 32)
def test_episode_probability_temp_dry_clear_and_missing():
s = {"times": TIMES_15, "now": NOW, "prob": [0] * 9, "temp": [10] * 9}
assert episode_probability_temp(s, NowcastResult(state="dry_clear")) == (None, None)
s2 = {"times": TIMES_15, "now": NOW, "prob": [], "temp": []}
assert episode_probability_temp(s2, NowcastResult(state="dry_incoming", minutes=30, bucket="rain")) == (None, None)
def test_format_amount_estimate_per_bucket():
# rain/showers -> liquid; snow -> depth from snow_cm; freezing -> liquid + "ice".
assert format_amount_estimate("rain", 5.08, 0.0, "in") == "0.2 in"
assert format_amount_estimate("heavy_rain", 12.7, 0.0, "in") == "0.5 in"
assert format_amount_estimate("snow", 8.6, 7.0, "in") == "2.8 in snow"
assert format_amount_estimate("freezing", 2.54, 0.0, "in") == "0.1 in ice"
assert format_amount_estimate("freezing", 5.0, 0.0, "mm") == "5.0 mm ice"
assert format_amount_estimate("drizzle", 0.0, 0.0, "in") is None # negligible -> no estimate
# --- precip family filter (the !rain vs !snow engine) -----------------------
def test_family_snow_ignores_rain():
# Rain incoming, no snow.
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]
assert analyze_precip_nowcast(TIMES_15, precip, codes, NOW, family=SNOW_FAMILY).state == "dry_clear"
r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, family=RAIN_FAMILY)
assert r.state == "dry_incoming" and r.bucket == "rain"
def test_family_rain_ignores_snow():
precip = [0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]
snow = [0.0, 0.0, 3.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0]
codes = [0, 0, 73, 73, 0, 0, 0, 0, 0]
assert analyze_precip_nowcast(TIMES_15, precip, codes, NOW, snow=snow, family=RAIN_FAMILY).state == "dry_clear"
r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, snow=snow, family=SNOW_FAMILY)
assert r.state == "dry_incoming" and r.bucket == "snow"
def test_family_smart_hunt_rain_now_snow_later():
# Rain at 14:15, then snow at 14:45-15:00. !snow finds the later snow;
# !rain finds the sooner rain. (The "smart hunt" the user asked for.)
precip = [0.0, 0.5, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0]
snow = [0.0, 0.0, 0.0, 3.0, 3.0, 0.0, 0.0, 0.0, 0.0]
codes = [0, 61, 0, 73, 73, 0, 0, 0, 0]
rsnow = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, snow=snow, family=SNOW_FAMILY)
assert rsnow.state == "dry_incoming" and rsnow.bucket == "snow" and rsnow.minutes == 45
rrain = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, snow=snow, family=RAIN_FAMILY)
assert rrain.state == "dry_incoming" and rrain.bucket == "rain" and rrain.minutes == 15
def test_family_none_still_sees_any_precip():
# No filter: a snow series is still detected (as the snow bucket).
precip = [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
snow = [3.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
codes = [73, 73, 0, 0, 0, 0, 0, 0, 0]
r = analyze_precip_nowcast(TIMES_15, precip, codes, NOW, snow=snow,
current_precip=0.5, current_code=73)
assert r.state == "raining_stopping" and r.bucket == "snow"
# --- join_location (the 'Spain, Spain' dedup) -------------------------------
def test_join_location_dedupes_equal_names():
# The reported bug: '!rain spain' -> city 'Spain' + country 'Spain'.
assert join_location("Spain", "Spain") == "Spain"
assert join_location("Singapore", "Singapore") == "Singapore"
assert join_location("spain", "Spain") == "Spain" # case-insensitive
def test_join_location_keeps_distinct_names():
assert join_location("Nashville", "TN") == "Nashville, TN"
assert join_location("Paris", "France") == "Paris, France"
def test_join_location_handles_missing_sides():
assert join_location("Nashville", None) == "Nashville"
assert join_location("Nashville", "") == "Nashville"
assert join_location(None, "France") == "France"
assert join_location("", "France") == "France"
assert join_location(None, None) == ""
def test_spain_end_to_end_label():
# city_display_name + join_location together, as _resolve_location does it.
suffix = "Spain"
typed_city = city_display_name("spain", suffix)
assert join_location(typed_city, suffix) == "Spain" # not "Spain, Spain"
# --- region_capital_query (bare country/state -> capital) --------------------
def test_region_capital_country():
assert region_capital_query("france") == "Paris, France"
assert region_capital_query("spain") == "Madrid, Spain"
assert region_capital_query("japan") == "Tokyo, Japan"
def test_region_capital_us_state():
assert region_capital_query("texas") == "Austin, TX"
assert region_capital_query("california") == "Sacramento, CA"
assert region_capital_query("georgia") == "Atlanta, GA" # US state wins over country
def test_region_capital_aliases_and_normalization():
assert region_capital_query("uk") == "London, United Kingdom"
assert region_capital_query("usa") == "Washington, United States"
assert region_capital_query("FRANCE") == "Paris, France" # case-insensitive
assert region_capital_query(" texas ") == "Austin, TX" # trimmed
def test_region_capital_excludes_city_dominant_states():
# New York / Washington almost always mean the city -> not defaulted.
assert region_capital_query("new york") is None
assert region_capital_query("washington") is None
def test_region_capital_non_regions_pass_through():
assert region_capital_query("paris") is None # a city, not a region
assert region_capital_query("nashville") is None
assert region_capital_query("paris, france") is None # already qualified
assert region_capital_query("37013") is None # a ZIP
assert region_capital_query("") is None
assert region_capital_query(None) is None
def test_region_note_fits_channel_budget():
# 160-byte channel cap minus 'WeatherBot-V3: ' prefix = 145 body bytes.
budget = 160 - len(b"WeatherBot-V3") - 2
worst_forecast = "🌧️ Heavy rain steady for 2h+ in Paris, France (est 0.5 in)"
combined = f"{worst_forecast} {REGION_DEFAULT_NOTE}"
assert len(combined.encode("utf-8")) <= budget
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""End-to-end tests for the proactive rain/snow push (Weather_Service).
Drives WeatherService._check_rain_nowcast() with synthetic Open-Meteo series and
asserts on the heads-up that gets pushed in particular the probability gate
(low-confidence "incoming" alerts are suppressed and left un-announced so they
can still fire later), snow depth, the rain-ending notice, and once-per-episode
dedup.
Sends are captured at bot.command_manager.send_channel_message; with a single
rain_channel configured the proactive push results in exactly one send, which
the assertions check.
"""
import asyncio
import configparser
from unittest.mock import Mock
from modules.service_plugins.weather_service import WeatherService
from tests.unit._rain_harness import make_series
# 15-min bucket presets (snowfall in cm, prob in %, temp in C).
_INCOMING_HI = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 10, 80, 80, 80])
_INCOMING_LO = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 10, 30, 30, 30])
_SNOW_INCOMING = dict(
precip=[0, 0, 0.3, 0.3, 0.3], codes=[0, 0, 71, 71, 71],
snow=[0, 0, 2.0, 2.0, 2.0], prob=[10, 10, 80, 80, 80],
)
_ENDING = dict(precip=[0.5, 0.5, 0, 0, 0], codes=[61, 61, 0, 0, 0], prob=[80] * 9,
current_precip=0.5, current_code=61)
def build_service(series, monkeypatch, *, overrides=None):
"""A WeatherService whose Open-Meteo fetch returns `series`, with the send
captured. Returns (service, sends) where sends is a list of (channel, text)."""
cfg = configparser.ConfigParser()
cfg.add_section("Weather")
cfg.add_section("Weather_Service")
cfg.set("Weather_Service", "my_position_lat", "36.16")
cfg.set("Weather_Service", "my_position_lon", "-86.78")
cfg.set("Weather_Service", "rain_nowcast_enabled", "true")
cfg.set("Weather_Service", "rain_channel", "weather") # single -> one send in both versions
for key, val in (overrides or {}).items():
cfg.set("Weather_Service", key, val)
bot = Mock()
bot.logger = Mock()
bot.config = cfg
bot.db_manager = Mock()
sends: list[tuple[str, str]] = []
async def _send_channel_message(channel, text, **kwargs):
sends.append((channel, text))
return True
bot.command_manager.send_channel_message = _send_channel_message
service = WeatherService(bot)
service.api_session = Mock()
# Pin the location label so _format_rain_nowcast doesn't reverse-geocode.
service._cached_rain_location = "Nashville, TN"
# get_mesh_flood_scope lazily imports heavy deps; stub it.
service.get_mesh_flood_scope = Mock(return_value=None)
monkeypatch.setattr(
"modules.service_plugins.weather_service.fetch_precip_series",
lambda *a, **k: series,
)
return service, sends
# --- probability gate -------------------------------------------------------
def test_incoming_above_threshold_pushes(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_HI), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
channel, text = sends[0]
assert channel == "weather"
assert text.startswith("🌧️ Heads up — Rain starting in ~30min")
assert "est" in text and "80%" in text
assert text.endswith("near Nashville, TN")
assert service._rain_start_announced is True
assert service._last_rain_start_time is not None
assert len(text.encode("utf-8")) <= 145
def test_incoming_below_threshold_is_gated_and_left_unannounced(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_LO), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert sends == [] # 30% < default 50% -> suppressed
# Critically: flag stays False so a later, higher-confidence poll can fire.
assert service._rain_start_announced is False
assert service._last_rain_start_time is None
def test_gate_respects_configured_min_probability(monkeypatch):
# Same 30% series, but lower the bar to 20% -> it should push.
service, sends = build_service(
make_series(**_INCOMING_LO), monkeypatch,
overrides={"rain_nowcast_min_probability": "20"},
)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
assert "30%" in sends[0][1]
# --- snow -------------------------------------------------------------------
def test_snow_incoming_pushes_depth(monkeypatch):
service, sends = build_service(make_series(**_SNOW_INCOMING), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
text = sends[0][1]
assert text.startswith("🌨️ Heads up — Snow starting in ~30min")
assert "in snow" in text
assert text.endswith("near Nashville, TN")
# --- ending -----------------------------------------------------------------
def test_rain_ending_pushes_when_enabled(monkeypatch):
service, sends = build_service(make_series(**_ENDING), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
text = sends[0][1]
assert text.startswith("🌧️ Heads up — Rain ending in ~")
assert service._rain_end_announced is True
def test_rain_ending_suppressed_when_disabled(monkeypatch):
service, sends = build_service(
make_series(**_ENDING), monkeypatch,
overrides={"rain_nowcast_announce_ending": "false"},
)
asyncio.run(service._check_rain_nowcast())
assert sends == []
# --- dedup ------------------------------------------------------------------
def test_fires_once_per_episode(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_HI), monkeypatch)
asyncio.run(service._check_rain_nowcast())
asyncio.run(service._check_rain_nowcast()) # same episode, already announced
assert len(sends) == 1
# --- gallery (visual): run with -s to eyeball the pushes --------------------
def test_gallery_prints_proactive_pushes(monkeypatch):
rows = []
for label, series, ov in [
("rain incoming (80%)", make_series(**_INCOMING_HI), None),
("rain incoming (30%)", make_series(**_INCOMING_LO), None),
("snow incoming (80%)", make_series(**_SNOW_INCOMING), None),
("rain ending", make_series(**_ENDING), None),
]:
service, sends = build_service(series, monkeypatch, overrides=ov)
asyncio.run(service._check_rain_nowcast())
out = sends[0][1] if sends else "(gated — no push)"
rows.append(f"{label:22s} -> {out}")
print("\n--- proactive rain/snow push gallery ---")
for r in rows:
print(r)
assert any("Heads up" in r for r in rows)
assert any("no push" in r for r in rows)
+9 -5
View File
@@ -6,7 +6,7 @@
"wx": ["wx", "weather", "wxa", "wxalert"],
"gwx": ["gwx", "globalweather", "gwxa"],
"aqi": ["aqi", "air", "airquality", "air_quality"],
"rain": ["rain", "nowcast"],
"rain": ["rain", "nowcast", "snow"],
"aurora": ["aurora", "kp"],
"solar": ["solar"],
"sun": ["sun"],
@@ -385,13 +385,17 @@
}
},
"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]",
"description": "Rain/snow nowcast: when precip starts or stops in the next ~2h, with amount",
"help_rain": "Rain nowcast: when rain starts/stops in ~2h, with amount. rain [city|zip|lat,lon]. Also: snow",
"help_snow": "Snow nowcast: when snow starts/stops in ~2h, with depth. snow [city|zip|lat,lon]. Also: rain",
"usage": "Usage: rain|snow [city|zipcode|lat,lon]",
"usage_short": "Usage: rain|snow [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}",
"clear": "☀️ No {precip} expected in next {window} for {location}",
"changeover": "{from_emoji}→{to_emoji} {first} {when} → {second} in ~{minutes}min for {location}",
"now": "now",
"duration_for": " (~{duration}min)",
"duration_open": " (steady)",
"error_fetching": "Error fetching rain nowcast data",