From d772b8a04e1f52d7e34a47a957789299732fa1ba Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 27 Jun 2026 15:52:15 -0700 Subject: [PATCH] feat(weather): integrate NWS coverage check for alerts - Added a new function `nws_http_means_no_coverage` to determine if NWS HTTP status codes indicate no coverage for a location. - Updated `WxCommand` and `WeatherService` classes to utilize this function, improving error handling for NOAA alerts. - Introduced a lazy loading mechanism for `_nws_alerts_available` to manage alert availability based on NWS coverage status. - Enhanced logging to provide clearer warnings when NWS alerts are unavailable due to HTTP errors. --- modules/commands/rain_command.py | 5 + modules/commands/wx_command.py | 21 ++- modules/service_plugins/weather_service.py | 19 ++- tests/unit/test_weather_alerts_nws.py | 168 +++++++++++++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_weather_alerts_nws.py diff --git a/modules/commands/rain_command.py b/modules/commands/rain_command.py index 9d8ca5f..522ec7e 100644 --- a/modules/commands/rain_command.py +++ b/modules/commands/rain_command.py @@ -408,6 +408,11 @@ def fetch_precip_series( return series +def nws_http_means_no_coverage(status_code: int) -> bool: + """True when an NWS HTTP status means the point has no US weather.gov coverage.""" + return status_code in (400, 404) + + # --- NWS gridpoint precip source --------------------------------------------- # WHY THIS EXISTS: the Open-Meteo *forecast model* (fetch_precip_series, above) # smooths away scattered, pop-up convection, so the nowcast can miss rain that is diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 2b38754..8bc573c 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -44,6 +44,7 @@ from ..clients.mqtt_weather import ( load_mqtt_weather_format_config, mqtt_weather_display_for_topic, ) +from .rain_command import nws_http_means_no_coverage # Multiday: plain digits (e.g. 7), 7day/7-day, or suffix form 7d/10d (min 2, max below). WX_MULTIDAY_MAX_DAYS = 16 @@ -121,6 +122,9 @@ class WxCommand(BaseCommand): # This makes the API more resilient to timeouts and transient errors self.noaa_session = self._create_retry_session() + # Lazy: None = unknown, False = NOAA alerts unavailable (non-US / no coverage) + self._nws_alerts_available = None + def _format_high_low(self, high: Optional[float], low: Optional[float], temp_symbol: str) -> str: """Format high/low using [Weather] temperature_*_format templates.""" return format_temperature_high_low(self.bot.config, high, low, temp_symbol, self.logger) @@ -1940,6 +1944,9 @@ class WxCommand(BaseCommand): If return_full_data=True: (list of alert dicts, alert_count) """ try: + if getattr(self, "_nws_alerts_available", None) is False: + return self.ERROR_FETCHING_DATA + # Round coordinates to 4 decimal places to avoid API redirects lat_rounded = round(lat, 4) lon_rounded = round(lon, 4) @@ -1949,12 +1956,24 @@ class WxCommand(BaseCommand): try: alert_data = self.noaa_session.get(alert_url, timeout=self.url_timeout) if not alert_data.ok: - self.logger.warning(f"Error fetching weather alerts from NOAA: HTTP {alert_data.status_code}") + if nws_http_means_no_coverage(alert_data.status_code): + self._nws_alerts_available = False + self.logger.warning( + "NWS weather alerts unavailable (HTTP %s); NOAA alerts are US-only — " + "skipping future alert requests", + alert_data.status_code, + ) + else: + self.logger.warning( + f"Error fetching weather alerts from NOAA: HTTP {alert_data.status_code}" + ) return self.ERROR_FETCHING_DATA except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: self.logger.warning(f"Timeout/connection error fetching weather alerts from NOAA: {e}") return self.ERROR_FETCHING_DATA + self._nws_alerts_available = True + alerts = [] # Store structured alert data alertxml = xml.dom.minidom.parseString(alert_data.text) diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index a9d8b24..b79714b 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -38,6 +38,7 @@ from ..commands.rain_command import ( fetch_precip_series_nws, format_amount_estimate, join_location, + nws_http_means_no_coverage, precip_descriptor, reverse_geocode_region, ) @@ -124,6 +125,9 @@ class WeatherService(BaseServicePlugin): # Track last alert check time to only send new alerts self.last_alert_check_time: Optional[float] = None + # Lazy: None = unknown, False = NOAA alerts unavailable (non-US / no coverage) + self._nws_alerts_available: Optional[bool] = None + # Background tasks self._alerts_task: Optional[asyncio.Task] = None self._forecast_task: Optional[asyncio.Task] = None @@ -715,6 +719,9 @@ class WeatherService(BaseServicePlugin): # Subsequent checks: only get alerts since last check time_window_start = self.last_alert_check_time + if self._nws_alerts_available is False: + return + # Round coordinates lat_rounded = round(self.my_position_lat, 4) lon_rounded = round(self.my_position_lon, 4) @@ -725,12 +732,22 @@ class WeatherService(BaseServicePlugin): try: alert_data = self.api_session.get(alert_url, timeout=10) if not alert_data.ok: - self.logger.debug(f"Error fetching alerts: HTTP {alert_data.status_code}") + if nws_http_means_no_coverage(alert_data.status_code): + self._nws_alerts_available = False + self.logger.warning( + "NWS weather alerts unavailable (HTTP %s); NOAA alerts are US-only — " + "skipping future alert polls", + alert_data.status_code, + ) + else: + self.logger.debug(f"Error fetching alerts: HTTP {alert_data.status_code}") return except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: self.logger.debug(f"Timeout/connection error fetching alerts: {e}") return + self._nws_alerts_available = True + # Parse ATOM feed with full metadata extraction (same as wx_command) alerts = [] alertxml = xml.dom.minidom.parseString(alert_data.text) diff --git a/tests/unit/test_weather_alerts_nws.py b/tests/unit/test_weather_alerts_nws.py new file mode 100644 index 0000000..c001db7 --- /dev/null +++ b/tests/unit/test_weather_alerts_nws.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Unit tests for lazy NWS alert coverage handling (international / HTTP 400).""" + +import asyncio +import configparser +from unittest.mock import AsyncMock, Mock + +import pytest +import requests + +from modules.commands.wx_command import WxCommand +from modules.service_plugins.weather_service import WeatherService + +_MINIMAL_ATOM = """ + + + urn:oid:2.49.test.alert + Test Warning issued June 27 at 10:00AM until June 28 at 6:00AM by NWS Seattle WA + 2026-06-27T10:00:00Z + +""" + + +def _build_bot(mock_logger, config): + bot = Mock() + bot.logger = mock_logger + bot.config = config + bot.db_manager = Mock() + bot.command_manager = Mock() + bot.command_manager.send_channel_message = AsyncMock() + return bot + + +def _weather_service(mock_logger, lat=51.5074, lon=-0.1278): + config = configparser.ConfigParser() + config.add_section("Weather") + config.add_section("Weather_Service") + config.set("Weather_Service", "my_position_lat", str(lat)) + config.set("Weather_Service", "my_position_lon", str(lon)) + service = WeatherService(_build_bot(mock_logger, config)) + return service + + +def _wx_command(mock_logger): + config = configparser.ConfigParser() + config.add_section("Weather") + config.set("Weather", "weather_provider", "noaa") + config.add_section("Wx_Command") + bot = _build_bot(mock_logger, config) + bot.db_manager.get_cached_geocoding = Mock(return_value=(None, None)) + bot.db_manager.cache_geocoding = Mock() + return WxCommand(bot) + + +def _mock_response(*, ok=True, status_code=200, text=""): + response = Mock() + response.ok = ok + response.status_code = status_code + response.text = text + return response + + +@pytest.mark.asyncio +async def test_weather_service_intl_400_skips_after_first_poll(mock_logger): + service = _weather_service(mock_logger) + call_count = 0 + + def _fake_get(_url, timeout=0): + nonlocal call_count + call_count += 1 + return _mock_response(ok=False, status_code=400) + + service.api_session = Mock() + service.api_session.get = _fake_get + + await service._check_weather_alerts() + await service._check_weather_alerts() + + assert call_count == 1 + assert service._nws_alerts_available is False + mock_logger.warning.assert_called_once() + + +@pytest.mark.asyncio +async def test_weather_service_us_200_sets_available(mock_logger): + service = _weather_service(mock_logger, lat=47.6062, lon=-122.3321) + service.api_session = Mock() + service.api_session.get = Mock( + return_value=_mock_response(ok=True, status_code=200, text=_MINIMAL_ATOM) + ) + + await service._check_weather_alerts() + + assert service._nws_alerts_available is True + service.api_session.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_weather_service_timeout_retries_next_poll(mock_logger): + service = _weather_service(mock_logger) + call_count = 0 + + def _fake_get(_url, timeout=0): + nonlocal call_count + call_count += 1 + raise requests.exceptions.Timeout("timed out") + + service.api_session = Mock() + service.api_session.get = _fake_get + + await service._check_weather_alerts() + await service._check_weather_alerts() + + assert call_count == 2 + assert service._nws_alerts_available is None + + +def test_wx_command_intl_400_skips_after_first_request(mock_logger): + cmd = _wx_command(mock_logger) + call_count = 0 + + def _fake_get(_url, timeout=0): + nonlocal call_count + call_count += 1 + return _mock_response(ok=False, status_code=400) + + cmd.noaa_session = Mock() + cmd.noaa_session.get = _fake_get + + assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA + assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA + + assert call_count == 1 + assert cmd._nws_alerts_available is False + mock_logger.warning.assert_called_once() + + +def test_wx_command_us_200_sets_available(mock_logger): + cmd = _wx_command(mock_logger) + cmd.noaa_session = Mock() + cmd.noaa_session.get = Mock( + return_value=_mock_response(ok=True, status_code=200, text=_MINIMAL_ATOM) + ) + + result = cmd.get_weather_alerts_noaa(47.6062, -122.3321, return_full_data=True) + + assert isinstance(result, tuple) + assert cmd._nws_alerts_available is True + cmd.noaa_session.get.assert_called_once() + + +def test_wx_command_timeout_retries_next_request(mock_logger): + cmd = _wx_command(mock_logger) + call_count = 0 + + def _fake_get(_url, timeout=0): + nonlocal call_count + call_count += 1 + raise requests.exceptions.ConnectionError("connection reset") + + cmd.noaa_session = Mock() + cmd.noaa_session.get = _fake_get + + assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA + assert cmd.get_weather_alerts_noaa(51.5074, -0.1278) == cmd.ERROR_FETCHING_DATA + + assert call_count == 2 + assert cmd._nws_alerts_available is None