diff --git a/modules/commands/rain_command.py b/modules/commands/rain_command.py index 3a76fc98..1eaf1aa0 100644 --- a/modules/commands/rain_command.py +++ b/modules/commands/rain_command.py @@ -6,8 +6,9 @@ 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 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 @@ "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 , 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) - - # Dry now: find the first upcoming precipitating bucket. - for idx, (mins, amt, code) in enumerate(upcoming): - if amt >= threshold: + # 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 (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 @@ def __init__(self, bot: Any) -> None: 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 @@ def _coordinates_to_location_string(self, lat: float, lon: float) -> Optional[st 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 @@ def _zip_to_city_string(self, zipcode: str) -> Optional[str]: 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 @@ def _resolve_location( # 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 @@ def _resolve_location( # — 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 @@ def _fetch_series(self, lat: float, lon: float) -> Optional[dict]: 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 @@ def _ptype(self, bucket: Optional[str]) -> str: 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 , but ..." 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 @@ def _format_result(self, result: NowcastResult, location_label: str) -> str: 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 @@ async def execute(self, message: MeshMessage) -> bool: 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 @@ async def execute(self, message: MeshMessage) -> bool: 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 , 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] + "..." diff --git a/modules/region_capitals.py b/modules/region_capitals.py new file mode 100644 index 00000000..170ea67d --- /dev/null +++ b/modules/region_capitals.py @@ -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 diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index e99a1bd7..d7bb0bc0 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -33,7 +33,10 @@ 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 @@ def __init__(self, bot: Any): 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 @@ async def _check_rain_nowcast(self) -> None: 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 @@ async def _check_rain_nowcast(self) -> None: 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 @@ async def _check_rain_nowcast(self) -> None: 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 @@ async def _check_rain_nowcast(self) -> None: 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 @@ async def _format_rain_nowcast(self, result: Any, kind: str) -> str: 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. diff --git a/tests/unit/_rain_harness.py b/tests/unit/_rain_harness.py new file mode 100644 index 00000000..610ffc87 --- /dev/null +++ b/tests/unit/_rain_harness.py @@ -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}/" diff --git a/tests/unit/test_rain_command_e2e.py b/tests/unit/test_rain_command_e2e.py new file mode 100644 index 00000000..e1825abc --- /dev/null +++ b/tests/unit/test_rain_command_e2e.py @@ -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 diff --git a/tests/unit/test_rain_live_smoke.py b/tests/unit/test_rain_live_smoke.py new file mode 100644 index 00000000..4cac9805 --- /dev/null +++ b/tests/unit/test_rain_live_smoke.py @@ -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}") diff --git a/tests/unit/test_rain_nowcast.py b/tests/unit/test_rain_nowcast.py index 173bb681..4f44f6f5 100644 --- a/tests/unit/test_rain_nowcast.py +++ b/tests/unit/test_rain_nowcast.py @@ -7,15 +7,23 @@ """ 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 diff --git a/tests/unit/test_rain_proactive_e2e.py b/tests/unit/test_rain_proactive_e2e.py new file mode 100644 index 00000000..40ce27a9 --- /dev/null +++ b/tests/unit/test_rain_proactive_e2e.py @@ -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) diff --git a/translations/en.json b/translations/en.json index c784faec..c1046942 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",