Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/accessiweather/weather_client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
190 changes: 190 additions & 0 deletions tests/test_weather_client_anomaly.py
Original file line number Diff line number Diff line change
@@ -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()
Loading