diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a03f53..5cecb6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added +- **Historical anomaly callouts** — current temperature is now compared against a multi-year historical baseline; you'll see a "Historical context" line in current conditions (e.g. "Currently 5.0°F warmer than the 5-year average for this date") when sufficient archive data is available (#325) + ## [0.4.5] - 2026-03-26 ### Added diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py index e0e8f757..e937b60b 100644 --- a/src/accessiweather/weather_client_base.py +++ b/src/accessiweather/weather_client_base.py @@ -42,6 +42,7 @@ WeatherData, ) from .notifications.minutely_precipitation import parse_pirate_weather_minutely_block +from .openmeteo_client import OpenMeteoApiClient from .pirate_weather_client import PirateWeatherApiError, PirateWeatherClient from .services import EnvironmentalDataClient from .units import resolve_auto_unit_system @@ -119,6 +120,9 @@ def __init__( # Cache of previous alerts per location key (for lifecycle diff) self._previous_alerts: dict[str, WeatherAlerts] = {} + # Lazy-created client for historical archive queries (anomaly computation) + self._openmeteo_archive_client: OpenMeteoApiClient | None = None + @property def visual_crossing_api_key(self) -> str: """ @@ -186,6 +190,20 @@ def avwx_api_key(self) -> str: return "" return str(key) + @property + def openmeteo_archive_client(self) -> OpenMeteoApiClient: + """Get (or lazily create) the Open-Meteo client used for archive queries.""" + if self._openmeteo_archive_client is None: + self._openmeteo_archive_client = OpenMeteoApiClient( + user_agent=self.user_agent, timeout=30.0 + ) + return self._openmeteo_archive_client + + @openmeteo_archive_client.setter + def openmeteo_archive_client(self, value: OpenMeteoApiClient | None) -> None: + """Allow direct assignment for testing.""" + self._openmeteo_archive_client = value + def _location_key(self, location: Location) -> str: """Generate a unique key for a location to track in-flight requests.""" return f"{location.name}:{location.latitude:.4f},{location.longitude:.4f}" @@ -1169,6 +1187,17 @@ def _launch_enrichment_tasks( self._enrich_with_visual_crossing_history(weather_data, location) ) + # Anomaly callout: compare current temperature against multi-year historical baseline. + # Skipped in test mode to avoid real network calls. + if ( + not self._test_mode + and weather_data.current + and weather_data.current.temperature_f is not None + ): + tasks["anomaly_callout"] = asyncio.create_task( + self._enrich_with_anomaly_callout(weather_data, location) + ) + # Post-processing enrichments (always run) tasks["environmental"] = asyncio.create_task( enrichment.populate_environmental_metrics(self, weather_data, location) @@ -1475,6 +1504,34 @@ async def get_aviation_weather( cwsu_id=cwsu_id, ) + async def _enrich_with_anomaly_callout( + self, weather_data: WeatherData, location: Location + ) -> None: + """Compute and attach a historical temperature anomaly callout to weather_data.""" + from datetime import date as _date + + from .weather_anomaly import compute_anomaly + + current = weather_data.current + if current is None or current.temperature_f is None: + return + + client = self.openmeteo_archive_client + lat = location.latitude + lon = location.longitude + temp_f = current.temperature_f + today = _date.today() + + loop = asyncio.get_running_loop() + try: + callout = await loop.run_in_executor( + None, + lambda: compute_anomaly(lat, lon, temp_f, today, client), + ) + weather_data.anomaly_callout = callout + except Exception as exc: # noqa: BLE001 + logger.debug("Anomaly callout computation failed: %s", exc) + async def _enrich_with_visual_crossing_history( self, weather_data: WeatherData, location: Location ) -> None: diff --git a/tests/test_weather_client_anomaly.py b/tests/test_weather_client_anomaly.py new file mode 100644 index 00000000..112d92db --- /dev/null +++ b/tests/test_weather_client_anomaly.py @@ -0,0 +1,190 @@ +"""Tests for anomaly callout enrichment in WeatherClient.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from accessiweather.models import ( + CurrentConditions, + Location, + WeatherData, +) +from accessiweather.weather_anomaly import AnomalyCallout +from accessiweather.weather_client import WeatherClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_location() -> Location: + return Location(name="Test City", latitude=40.0, longitude=-74.0) + + +def _make_weather_data(temp_f: float | None = 72.0) -> WeatherData: + current = CurrentConditions() + current.temperature_f = temp_f + return WeatherData(location=_make_location(), current=current) + + +def _make_callout(anomaly: float = 5.0) -> AnomalyCallout: + return AnomalyCallout( + temp_anomaly=anomaly, + temp_anomaly_description=f"Currently {abs(anomaly):.1f}°F warmer than average.", + precip_anomaly_description=None, + severity="significant", + ) + + +# --------------------------------------------------------------------------- +# _enrich_with_anomaly_callout +# --------------------------------------------------------------------------- + + +class TestEnrichWithAnomalyCallout: + @pytest.fixture() + def client(self): + """WeatherClient NOT in test mode (so anomaly runs).""" + c = WeatherClient() + c._test_mode = False + return c + + @pytest.mark.asyncio + async def test_sets_anomaly_callout_on_success(self, client): + """When compute_anomaly returns a callout it is stored on weather_data.""" + expected = _make_callout(5.0) + weather_data = _make_weather_data(72.0) + location = _make_location() + + with ( + patch( + "accessiweather.weather_client_base.WeatherClient._enrich_with_anomaly_callout", + wraps=client._enrich_with_anomaly_callout, + ), + patch("accessiweather.weather_anomaly.compute_anomaly", return_value=expected), + ): + await client._enrich_with_anomaly_callout(weather_data, location) + + assert weather_data.anomaly_callout is expected + + @pytest.mark.asyncio + async def test_sets_none_when_compute_returns_none(self, client): + """When compute_anomaly returns None (insufficient data), anomaly_callout stays None.""" + weather_data = _make_weather_data(72.0) + location = _make_location() + + with patch("accessiweather.weather_anomaly.compute_anomaly", return_value=None): + await client._enrich_with_anomaly_callout(weather_data, location) + + assert weather_data.anomaly_callout is None + + @pytest.mark.asyncio + async def test_skips_when_no_temperature(self, client): + """Enrichment is a no-op when current temperature_f is None.""" + weather_data = _make_weather_data(temp_f=None) + location = _make_location() + + with patch("accessiweather.weather_anomaly.compute_anomaly") as mock_compute: + await client._enrich_with_anomaly_callout(weather_data, location) + mock_compute.assert_not_called() + + assert weather_data.anomaly_callout is None + + @pytest.mark.asyncio + async def test_skips_when_no_current(self, client): + """Enrichment is a no-op when current conditions are absent.""" + weather_data = WeatherData(location=_make_location()) + location = _make_location() + + with patch("accessiweather.weather_anomaly.compute_anomaly") as mock_compute: + await client._enrich_with_anomaly_callout(weather_data, location) + mock_compute.assert_not_called() + + assert weather_data.anomaly_callout is None + + @pytest.mark.asyncio + async def test_handles_exception_gracefully(self, client): + """A compute_anomaly exception must not propagate; anomaly_callout stays None.""" + weather_data = _make_weather_data(72.0) + location = _make_location() + + with patch( + "accessiweather.weather_anomaly.compute_anomaly", + side_effect=RuntimeError("archive unavailable"), + ): + # Should not raise + await client._enrich_with_anomaly_callout(weather_data, location) + + assert weather_data.anomaly_callout is None + + +# --------------------------------------------------------------------------- +# openmeteo_archive_client property +# --------------------------------------------------------------------------- + + +class TestOpenMeteoArchiveClientProperty: + def test_lazy_creation(self): + client = WeatherClient() + assert client._openmeteo_archive_client is None + archive = client.openmeteo_archive_client + assert archive is not None + # Second access returns same instance + assert client.openmeteo_archive_client is archive + + def test_setter_for_testing(self): + client = WeatherClient() + mock_archive = MagicMock() + client.openmeteo_archive_client = mock_archive + assert client.openmeteo_archive_client is mock_archive + + +# --------------------------------------------------------------------------- +# _launch_enrichment_tasks — anomaly task inclusion +# --------------------------------------------------------------------------- + + +class TestLaunchEnrichmentTasksAnomaly: + @pytest.mark.asyncio + async def test_anomaly_task_skipped_in_test_mode(self): + """In test mode the anomaly task must NOT be created.""" + client = WeatherClient() + assert client._test_mode is True # pytest sets PYTEST_CURRENT_TEST + + weather_data = _make_weather_data(72.0) + location = _make_location() + + tasks = client._launch_enrichment_tasks(weather_data, location) + assert "anomaly_callout" not in tasks + for t in tasks.values(): + t.cancel() + + @pytest.mark.asyncio + async def test_anomaly_task_created_when_not_test_mode(self): + """Outside test mode, the anomaly task is created when temperature is available.""" + client = WeatherClient() + client._test_mode = False + + weather_data = _make_weather_data(72.0) + location = _make_location() + + tasks = client._launch_enrichment_tasks(weather_data, location) + assert "anomaly_callout" in tasks + for t in tasks.values(): + t.cancel() + + @pytest.mark.asyncio + async def test_anomaly_task_not_created_when_no_temperature(self): + """Without a temperature reading the anomaly task must not be created.""" + client = WeatherClient() + client._test_mode = False + + weather_data = _make_weather_data(temp_f=None) + location = _make_location() + + tasks = client._launch_enrichment_tasks(weather_data, location) + assert "anomaly_callout" not in tasks + for t in tasks.values(): + t.cancel()