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.
This commit is contained in:
agessaman
2026-06-27 15:52:15 -07:00
parent 3b6ef66fd9
commit d772b8a04e
4 changed files with 211 additions and 2 deletions
+5
View File
@@ -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
+20 -1
View File
@@ -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)
+18 -1
View File
@@ -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)
+168
View File
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>urn:oid:2.49.test.alert</id>
<title>Test Warning issued June 27 at 10:00AM until June 28 at 6:00AM by NWS Seattle WA</title>
<updated>2026-06-27T10:00:00Z</updated>
</entry>
</feed>"""
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