mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-27 21:10:13 +00:00
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:
@@ -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] + "..."
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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}/"
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user