diff --git a/src/accessiweather/display/presentation/current_conditions.py b/src/accessiweather/display/presentation/current_conditions.py index 7732ecdf..e5bbab5c 100644 --- a/src/accessiweather/display/presentation/current_conditions.py +++ b/src/accessiweather/display/presentation/current_conditions.py @@ -5,6 +5,7 @@ import logging from collections.abc import Iterable +from ...impact_summary import build_impact_summary from ...models import ( AppSettings, CurrentConditions, @@ -565,6 +566,15 @@ def build_current_conditions( if anomaly_callout is not None: metrics.append(Metric("Historical context", anomaly_callout.temp_anomaly_description)) + # Build impact summary and append as metrics + impact = build_impact_summary(current, environmental) + if impact.outdoor is not None: + metrics.append(Metric("Impact: Outdoor", impact.outdoor)) + if impact.driving is not None: + metrics.append(Metric("Impact: Driving", impact.driving)) + if impact.allergy is not None: + metrics.append(Metric("Impact: Allergy", impact.allergy)) + # Build fallback text # Use metric.label for all metrics — after priority reordering, metrics[0] # may no longer be the Temperature metric (e.g. Visibility moves first during fog alerts) @@ -588,6 +598,7 @@ def build_current_conditions( metrics=metrics, fallback_text=fallback_text, trends=trend_lines, + impact_summary=impact, ) diff --git a/src/accessiweather/display/presentation/forecast.py b/src/accessiweather/display/presentation/forecast.py index 4994e921..b1d2166f 100644 --- a/src/accessiweather/display/presentation/forecast.py +++ b/src/accessiweather/display/presentation/forecast.py @@ -6,6 +6,7 @@ from datetime import UTC, date, datetime, tzinfo from ...forecast_confidence import ForecastConfidence +from ...impact_summary import ImpactSummary, build_forecast_impact_summary from ...models import AppSettings, Forecast, ForecastPeriod, HourlyForecast, Location from ...utils import TemperatureUnit, calculate_dewpoint from ...utils.unit_utils import format_precipitation, format_wind_speed @@ -300,6 +301,12 @@ def build_forecast( fallback_sections.append(hourly_section_text) fallback_text = "\n\n".join(section for section in fallback_sections if section).rstrip() + # Derive an impact summary from the first available forecast period + first_period = selected_periods[0] if selected_periods else None + forecast_impact: ImpactSummary | None = ( + build_forecast_impact_summary(first_period) if first_period is not None else None + ) + return ForecastPresentation( title=title, periods=periods, @@ -311,6 +318,7 @@ def build_forecast( hourly_section_text=hourly_section_text, confidence_label=confidence_label, summary=summary_line, + impact_summary=forecast_impact, ) diff --git a/src/accessiweather/display/weather_presenter.py b/src/accessiweather/display/weather_presenter.py index 15a2bf4d..57f9fc06 100644 --- a/src/accessiweather/display/weather_presenter.py +++ b/src/accessiweather/display/weather_presenter.py @@ -16,6 +16,7 @@ from accessiweather.alert_lifecycle import AlertLifecycleDiff from ..forecast_confidence import ForecastConfidence + from ..impact_summary import ImpactSummary from ..models import ( AppSettings, @@ -86,6 +87,7 @@ class CurrentConditionsPresentation: metrics: list[Metric] = field(default_factory=list) fallback_text: str = "" trends: list[str] = field(default_factory=list) + impact_summary: ImpactSummary | None = None @property def trend_summary(self) -> list[str]: # pragma: no cover - backward compatibility @@ -107,6 +109,7 @@ class ForecastPresentation: hourly_section_text: str = "" confidence_label: str | None = None summary: str | None = None + impact_summary: ImpactSummary | None = None @dataclass(slots=True) diff --git a/src/accessiweather/impact_summary.py b/src/accessiweather/impact_summary.py new file mode 100644 index 00000000..fb014292 --- /dev/null +++ b/src/accessiweather/impact_summary.py @@ -0,0 +1,365 @@ +""" +Rule-based weather impact summaries for outdoor, driving, and allergy guidance. + +All rules are fully documented and unit-testable — no AI or opaque logic. + +Rule sets +--------- +Outdoor + Based on feels-like (or actual) temperature, UV index, and active precipitation. + +Driving + Based on visibility, precipitation type, near-freezing temperature, and wind. + +Allergy + Based on pollen category/index, primary allergen, wind dispersion, and air quality. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .models import CurrentConditions, EnvironmentalConditions, ForecastPeriod + + +@dataclass(slots=True) +class ImpactSummary: + """Human-readable impact guidance for three lifestyle contexts.""" + + outdoor: str | None = None + driving: str | None = None + allergy: str | None = None + + def has_content(self) -> bool: + """Return True when at least one impact area has guidance.""" + return any([self.outdoor, self.driving, self.allergy]) + + +# ── Outdoor guidance ────────────────────────────────────────────────────────── + +# Temperature comfort bands (Fahrenheit, applied to feels-like or actual temp) +# Each entry: (upper_exclusive, label) +_OUTDOOR_TEMP_BANDS: list[tuple[float, str]] = [ + (0, "Dangerous cold - avoid prolonged outdoor exposure"), + (15, "Extreme cold - dress in heavy layers, limit time outside"), + (25, "Very cold - heavy winter clothing required"), + (32, "Cold - wear a heavy coat"), + (50, "Cool - coat or warm jacket recommended"), + (60, "Mild - light jacket may be needed"), + (78, "Comfortable - good conditions for outdoor activities"), + (85, "Warm - pleasant for most outdoor activities"), + (95, "Hot - stay hydrated and seek shade during peak hours"), + (105, "Very hot - limit strenuous outdoor activity"), + (float("inf"), "Extreme heat - avoid outdoor exertion"), +] + +_PRECIP_CONDITION_KEYWORDS = frozenset( + ["rain", "snow", "storm", "drizzle", "shower", "sleet", "hail", "flurr"] +) + + +def _outdoor_from_conditions( + feels_like_f: float | None, + temp_f: float | None, + uv_index: float | None, + condition: str | None, +) -> str | None: + """ + Return concise outdoor guidance from current conditions. + + Rules (in order of application): + 1. Selects comfort band from feels-like temperature (falls back to actual temp). + 2. Appends UV protection note when UV index >= 6. + 3. Appends precipitation warning when condition text implies active precipitation. + """ + ref_temp = feels_like_f if feels_like_f is not None else temp_f + if ref_temp is None: + return None + + comfort = next(label for upper, label in _OUTDOOR_TEMP_BANDS if ref_temp < upper) + + modifiers: list[str] = [] + + if uv_index is not None: + if uv_index >= 8: + modifiers.append("UV very high - sun protection essential") + elif uv_index >= 6: + modifiers.append("wear sunscreen") + + condition_lower = (condition or "").lower() + if any(kw in condition_lower for kw in _PRECIP_CONDITION_KEYWORDS): + modifiers.append("active precipitation - bring appropriate gear") + + if modifiers: + return f"{comfort}; {'; '.join(modifiers)}" + return comfort + + +# ── Driving guidance ────────────────────────────────────────────────────────── + +_ICE_KEYWORDS = frozenset(["ice", "freezing", "sleet", "glaze"]) +_SNOW_KEYWORDS = frozenset(["snow", "blizzard", "flurr"]) +_RAIN_KEYWORDS = frozenset(["rain", "downpour", "drizzle", "shower"]) +_THUNDER_KEYWORDS = frozenset(["thunder", "storm", "lightning"]) + + +def _has_keyword(text: str, keywords: frozenset[str]) -> bool: + lower = text.lower() + return any(kw in lower for kw in keywords) + + +def _driving_from_conditions( + visibility_miles: float | None, + wind_speed_mph: float | None, + wind_gust_mph: float | None, + temp_f: float | None, + condition: str | None, + precipitation_type: list[str] | None, +) -> str | None: + """ + Return concise driving guidance from current conditions. + + Rules (in order of priority): + 1. Visibility: < 0.25 mi → near-zero; < 1 mi → very low; < 3 mi → reduced. + 2. Precipitation type (explicit list or condition text): + ice/freezing > snow > thunderstorm > rain. + 3. Near-freezing temperature (25–36 °F) with any moisture hints → black-ice warning. + 4. Wind: >= 45 mph → dangerous; >= 30 → high; >= 20 → gusty. + """ + issues: list[str] = [] + + # 1. Visibility + if visibility_miles is not None: + if visibility_miles < 0.25: + issues.append("near-zero visibility - do not drive unless essential") + elif visibility_miles < 1.0: + issues.append("very low visibility - drive with extreme caution") + elif visibility_miles < 3.0: + issues.append("reduced visibility - drive carefully") + + # 2. Precipitation type + precip_text = " ".join(precipitation_type or []) + " " + (condition or "") + if _has_keyword(precip_text, _ICE_KEYWORDS): + issues.append("ice possible - slow down, allow extra stopping distance") + elif _has_keyword(precip_text, _SNOW_KEYWORDS): + issues.append("snow on roads - reduce speed and increase following distance") + elif _has_keyword(precip_text, _THUNDER_KEYWORDS): + issues.append("thunderstorms - avoid driving if possible") + elif _has_keyword(precip_text, _RAIN_KEYWORDS): + issues.append("wet roads - allow extra stopping distance") + + # 3. Near-freezing black-ice risk (only if no ice warning already added) + if temp_f is not None and 25 <= temp_f <= 36: + cond_lower = (condition or "").lower() + moisture_present = any( + kw in cond_lower + for kw in ["rain", "drizzle", "snow", "sleet", "cloud", "fog", "mist", "overcast"] + ) + if moisture_present and not any("ice" in issue for issue in issues): + issues.append("near-freezing temperatures - watch for black ice") + + # 4. Wind + effective_wind = max(wind_speed_mph or 0.0, wind_gust_mph or 0.0) + if effective_wind >= 45: + issues.append("dangerous winds - high-profile vehicles at serious risk") + elif effective_wind >= 30: + issues.append("high winds - caution especially for tall vehicles") + elif effective_wind >= 20: + issues.append("gusty winds - minor effect on steering") + + if not issues: + return "Normal driving conditions" + return "Caution: " + "; ".join(issues) + + +# ── Allergy guidance ────────────────────────────────────────────────────────── + +# Higher rank = more severe pollen risk +_POLLEN_CATEGORY_RANK: dict[str, int] = { + "None": 0, + "Very Low": 1, + "Low": 2, + "Moderate": 3, + "High": 4, + "Very High": 5, + "Extreme": 6, +} + +_AQ_CATEGORY_RANK: dict[str, int] = { + "Good": 1, + "Moderate": 2, + "Unhealthy for Sensitive Groups": 3, + "Unhealthy": 4, + "Very Unhealthy": 5, + "Hazardous": 6, +} + + +def _allergy_from_conditions( + pollen_index: float | None, + pollen_category: str | None, + pollen_primary_allergen: str | None, + wind_speed_mph: float | None, + air_quality_category: str | None, +) -> str | None: + """ + Return allergy/outdoor air guidance. + + Rules: + 1. Pollen category maps to a severity band; allergen name appended when available. + 2. Wind >= 15 mph with moderate-or-higher pollen triggers a dispersion note. + 3. Unhealthy (or worse) AQI adds an exposure-limit note. + """ + parts: list[str] = [] + + category_rank = _POLLEN_CATEGORY_RANK.get(pollen_category or "", -1) + allergen_note = f" ({pollen_primary_allergen})" if pollen_primary_allergen else "" + + if pollen_category is not None: + if category_rank >= 5: + parts.append(f"Very high pollen{allergen_note} - take allergy precautions") + elif category_rank == 4: + parts.append( + f"High pollen{allergen_note} - sensitive individuals should limit exposure" + ) + elif category_rank == 3: + parts.append( + f"Moderate pollen{allergen_note} - sensitive individuals may experience symptoms" + ) + elif category_rank in (1, 2): + parts.append(f"Low pollen{allergen_note}") + else: + parts.append(f"Pollen: {pollen_category}{allergen_note}") + elif pollen_index is not None: + if pollen_index >= 10: + parts.append("High pollen index - allergy precautions recommended") + elif pollen_index >= 5: + parts.append("Moderate pollen index") + else: + parts.append("Low pollen index") + + # Wind dispersion note + if parts and wind_speed_mph is not None and wind_speed_mph >= 15 and category_rank >= 3: + parts.append("wind increasing pollen dispersion") + + # Air quality note + aq_rank = _AQ_CATEGORY_RANK.get(air_quality_category or "", 0) + if aq_rank >= 4: + parts.append(f"air quality {air_quality_category} - limit outdoor exposure") + elif aq_rank == 3: + parts.append("air quality unhealthy for sensitive groups") + + return "; ".join(parts) if parts else None + + +# ── Public API ───────────────────────────────────────────────────────────────── + + +def build_impact_summary( + current: CurrentConditions | None, + environmental: EnvironmentalConditions | None = None, +) -> ImpactSummary: + """ + Derive impact summaries from current conditions and environmental data. + + Args: + current: Current weather conditions. Returns an empty summary when None. + environmental: Optional environmental conditions (pollen, AQI). + + Returns: + An ImpactSummary with outdoor, driving, and allergy fields populated + where sufficient data is available. + + """ + if not current: + return ImpactSummary() + + outdoor = _outdoor_from_conditions( + feels_like_f=current.feels_like_f, + temp_f=current.temperature_f, + uv_index=current.uv_index, + condition=current.condition, + ) + + driving = _driving_from_conditions( + visibility_miles=current.visibility_miles, + wind_speed_mph=current.wind_speed_mph, + wind_gust_mph=current.wind_gust_mph, + temp_f=current.temperature_f, + condition=current.condition, + precipitation_type=current.precipitation_type, + ) + + allergy = _allergy_from_conditions( + pollen_index=environmental.pollen_index if environmental else None, + pollen_category=environmental.pollen_category if environmental else None, + pollen_primary_allergen=environmental.pollen_primary_allergen if environmental else None, + wind_speed_mph=current.wind_speed_mph, + air_quality_category=environmental.air_quality_category if environmental else None, + ) + + return ImpactSummary(outdoor=outdoor, driving=driving, allergy=allergy) + + +def build_forecast_impact_summary( + period: ForecastPeriod, +) -> ImpactSummary: + """ + Derive impact summaries from a forecast period. + + Args: + period: A single forecast period from the daily or hourly forecast. + + Returns: + An ImpactSummary derived from the period's temperature, wind, and conditions. + + """ + # Normalise temperature to Fahrenheit + temp_f: float | None = None + if period.temperature is not None: + temp_f = ( + float(period.temperature) * 9 / 5 + 32 + if getattr(period, "temperature_unit", "F") == "C" + else float(period.temperature) + ) + + # Approximate feels-like from feels_like_high, falling back to temp + feels_f = ( + period.feels_like_high if getattr(period, "feels_like_high", None) is not None else temp_f + ) + + outdoor = _outdoor_from_conditions( + feels_like_f=feels_f, + temp_f=temp_f, + uv_index=getattr(period, "uv_index_max", None) or period.uv_index, + condition=period.short_forecast, + ) + + # Extract a numeric wind speed from the string, e.g. "15 mph" or "15 to 25 mph" + wind_mph: float | None = None + wind_str = period.wind_speed or "" + nums = re.findall(r"\d+(?:\.\d+)?", wind_str) + if nums: + wind_mph = max(float(n) for n in nums) + + driving = _driving_from_conditions( + visibility_miles=None, + wind_speed_mph=wind_mph, + wind_gust_mph=None, + temp_f=temp_f, + condition=period.short_forecast, + precipitation_type=getattr(period, "precipitation_type", None), + ) + + allergy = _allergy_from_conditions( + pollen_index=None, + pollen_category=getattr(period, "pollen_forecast", None), + pollen_primary_allergen=None, + wind_speed_mph=wind_mph, + air_quality_category=None, + ) + + return ImpactSummary(outdoor=outdoor, driving=driving, allergy=allergy) diff --git a/tests/test_impact_summary.py b/tests/test_impact_summary.py new file mode 100644 index 00000000..90aade7e --- /dev/null +++ b/tests/test_impact_summary.py @@ -0,0 +1,884 @@ +""" +Unit tests for accessiweather.impact_summary. + +Covers: +- Outdoor guidance temperature bands +- Outdoor UV index modifiers +- Outdoor active precipitation modifier +- Driving: visibility thresholds +- Driving: precipitation-type detection (ice, snow, thunder, rain) +- Driving: near-freezing black-ice warning +- Driving: wind thresholds +- Driving: normal conditions baseline +- Allergy: pollen category bands +- Allergy: wind dispersion modifier +- Allergy: air quality modifier +- Allergy: pollen index fallback +- build_impact_summary: wires all three areas +- build_forecast_impact_summary: derives from ForecastPeriod +- ImpactSummary.has_content +""" + +from __future__ import annotations + +import pytest + +from accessiweather.impact_summary import ( + ImpactSummary, + _allergy_from_conditions, + _driving_from_conditions, + _outdoor_from_conditions, + build_forecast_impact_summary, + build_impact_summary, +) +from accessiweather.models.weather import ( + CurrentConditions, + EnvironmentalConditions, + ForecastPeriod, +) + +# ── ImpactSummary helpers ────────────────────────────────────────────────────── + + +class TestImpactSummaryHasContent: + def test_empty(self): + assert ImpactSummary().has_content() is False + + def test_outdoor_only(self): + assert ImpactSummary(outdoor="Hot").has_content() is True + + def test_driving_only(self): + assert ImpactSummary(driving="Caution").has_content() is True + + def test_allergy_only(self): + assert ImpactSummary(allergy="High pollen").has_content() is True + + def test_all_fields(self): + assert ImpactSummary(outdoor="A", driving="B", allergy="C").has_content() is True + + +# ── Outdoor guidance ────────────────────────────────────────────────────────── + + +class TestOutdoorTemperatureBands: + @pytest.mark.parametrize( + "temp_f, expected_fragment", + [ + (-5, "Dangerous cold"), + (10, "Extreme cold"), + (20, "Very cold"), + (28, "Cold"), + (45, "Cool"), + (55, "Mild"), + (70, "Comfortable"), + (80, "Warm"), + (90, "Hot"), + (100, "Very hot"), + (110, "Extreme heat"), + ], + ) + def test_temperature_bands(self, temp_f, expected_fragment): + result = _outdoor_from_conditions( + feels_like_f=temp_f, temp_f=temp_f, uv_index=None, condition=None + ) + assert result is not None + assert expected_fragment.lower() in result.lower() + + def test_feels_like_takes_priority_over_temp(self): + # feels_like much colder than actual temp + result = _outdoor_from_conditions(feels_like_f=5, temp_f=40, uv_index=None, condition=None) + assert result is not None + assert "extreme cold" in result.lower() + + def test_no_temp_returns_none(self): + result = _outdoor_from_conditions( + feels_like_f=None, temp_f=None, uv_index=None, condition=None + ) + assert result is None + + def test_boundary_exact_zero(self): + # Exactly 0°F → Extreme cold band (upper_exclusive=0 means < 0 is Dangerous cold) + result = _outdoor_from_conditions(feels_like_f=0, temp_f=0, uv_index=None, condition=None) + assert result is not None + assert "extreme cold" in result.lower() + + def test_boundary_at_freezing(self): + # Exactly 32°F → Cool band (upper_exclusive=32 means < 32 is Cold) + result = _outdoor_from_conditions(feels_like_f=32, temp_f=32, uv_index=None, condition=None) + assert result is not None + assert "cool" in result.lower() + + def test_actual_temp_fallback_when_no_feels_like(self): + result = _outdoor_from_conditions( + feels_like_f=None, temp_f=70, uv_index=None, condition=None + ) + assert result is not None + assert "comfortable" in result.lower() + + +class TestOutdoorUVModifier: + def test_uv_very_high(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=9, condition=None) + assert result is not None + assert "uv very high" in result.lower() + + def test_uv_high(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=7, condition=None) + assert result is not None + assert "sunscreen" in result.lower() + + def test_uv_low_no_modifier(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=3, condition=None) + assert result is not None + assert "sunscreen" not in result.lower() + assert "uv" not in result.lower() + + def test_uv_exactly_8_triggers_very_high(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=8, condition=None) + assert result is not None + assert "uv very high" in result.lower() + + def test_uv_exactly_6_triggers_sunscreen(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=6, condition=None) + assert result is not None + assert "sunscreen" in result.lower() + + def test_uv_none_no_modifier(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=None, condition=None) + assert result is not None + assert "uv" not in result.lower() + + +class TestOutdoorPrecipitationModifier: + @pytest.mark.parametrize( + "condition", + ["Light Rain", "Heavy Snow", "Thunderstorm", "Drizzle", "Sleet", "Hail", "Snow Flurries"], + ) + def test_active_precip_adds_modifier(self, condition): + result = _outdoor_from_conditions( + feels_like_f=65, temp_f=65, uv_index=None, condition=condition + ) + assert result is not None + assert "precipitation" in result.lower() + + def test_clear_condition_no_precip_modifier(self): + result = _outdoor_from_conditions( + feels_like_f=72, temp_f=72, uv_index=None, condition="Mostly Sunny" + ) + assert result is not None + assert "precipitation" not in result.lower() + + def test_none_condition_no_precip_modifier(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=None, condition=None) + assert result is not None + assert "precipitation" not in result.lower() + + def test_multiple_modifiers_joined_by_semicolon(self): + result = _outdoor_from_conditions(feels_like_f=72, temp_f=72, uv_index=9, condition="Rain") + assert result is not None + assert ";" in result + assert "uv very high" in result.lower() + assert "precipitation" in result.lower() + + +# ── Driving guidance ────────────────────────────────────────────────────────── + + +class TestDrivingVisibility: + def test_near_zero(self): + result = _driving_from_conditions(0.1, None, None, 50, None, None) + assert result is not None + assert "near-zero visibility" in result.lower() + + def test_very_low(self): + result = _driving_from_conditions(0.5, None, None, 50, None, None) + assert result is not None + assert "very low visibility" in result.lower() + + def test_reduced(self): + result = _driving_from_conditions(2.0, None, None, 50, None, None) + assert result is not None + assert "reduced visibility" in result.lower() + + def test_good_visibility_no_mention(self): + result = _driving_from_conditions(10.0, None, None, 50, None, None) + assert result is not None + assert "visibility" not in result.lower() + + def test_boundary_exactly_025(self): + # 0.25 is not < 0.25, so should be "very low" (< 1.0) + result = _driving_from_conditions(0.25, None, None, 50, None, None) + assert result is not None + assert "very low visibility" in result.lower() + + def test_boundary_exactly_1(self): + # 1.0 is not < 1.0, so should be "reduced" (< 3.0) + result = _driving_from_conditions(1.0, None, None, 50, None, None) + assert result is not None + assert "reduced visibility" in result.lower() + + def test_boundary_exactly_3(self): + # 3.0 is not < 3.0, so no visibility warning + result = _driving_from_conditions(3.0, None, None, 50, None, None) + assert result is not None + assert "visibility" not in result.lower() + + +class TestDrivingPrecipitationType: + def test_ice_from_precip_list(self): + result = _driving_from_conditions(None, None, None, 30, None, ["ice"]) + assert result is not None + assert "ice" in result.lower() + + def test_freezing_rain_from_condition(self): + result = _driving_from_conditions(None, None, None, 30, "Freezing Rain", None) + assert result is not None + assert "ice" in result.lower() + + def test_sleet_from_condition(self): + result = _driving_from_conditions(None, None, None, 30, "Sleet", None) + assert result is not None + assert "ice" in result.lower() + + def test_snow_from_precip_list(self): + result = _driving_from_conditions(None, None, None, 28, None, ["snow"]) + assert result is not None + assert "snow" in result.lower() + + def test_blizzard_from_condition(self): + result = _driving_from_conditions(None, None, None, 20, "Blizzard", None) + assert result is not None + assert "snow" in result.lower() + + def test_thunderstorm_from_condition(self): + result = _driving_from_conditions(None, None, None, 65, "Thunderstorm", None) + assert result is not None + assert "thunderstorm" in result.lower() + + def test_rain_from_condition(self): + result = _driving_from_conditions(None, None, None, 55, "Light Rain", None) + assert result is not None + assert "wet roads" in result.lower() + + def test_drizzle_from_condition(self): + result = _driving_from_conditions(None, None, None, 55, "Drizzle", None) + assert result is not None + assert "wet roads" in result.lower() + + def test_ice_takes_priority_over_snow(self): + # Both ice and snow keywords: ice should win + result = _driving_from_conditions(None, None, None, 28, "Freezing Snow", None) + assert result is not None + assert "ice" in result.lower() + + def test_snow_takes_priority_over_thunder(self): + result = _driving_from_conditions(None, None, None, 28, "Snow Storm", None) + assert result is not None + assert "snow" in result.lower() + + +class TestDrivingBlackIce: + def test_near_freezing_with_cloudy_triggers_warning(self): + result = _driving_from_conditions(None, None, None, 32, "Overcast", None) + assert result is not None + assert "black ice" in result.lower() + + def test_near_freezing_with_fog_triggers_warning(self): + result = _driving_from_conditions(None, None, None, 30, "Foggy", None) + assert result is not None + assert "black ice" in result.lower() + + def test_near_freezing_clear_no_warning(self): + result = _driving_from_conditions(None, None, None, 32, "Sunny", None) + # Clear/sunny has no moisture keywords, should not trigger black ice + assert result is not None + assert "black ice" not in result.lower() + + def test_warm_temp_no_ice_warning(self): + result = _driving_from_conditions(None, None, None, 60, "Rain", None) + assert result is not None + assert "black ice" not in result.lower() + + def test_too_cold_no_black_ice_warning(self): + # Below 25°F → outside the near-freezing band + result = _driving_from_conditions(None, None, None, 10, "Cloudy", None) + assert result is not None + assert "black ice" not in result.lower() + + def test_ice_already_present_no_duplicate(self): + # Freezing rain already triggers ice warning; black ice should not be duplicated + result = _driving_from_conditions(None, None, None, 32, "Freezing Rain Overcast", None) + assert result is not None + assert result.count("ice") == 1 or "near-freezing" not in result.lower() + + +class TestDrivingWind: + def test_dangerous_wind(self): + result = _driving_from_conditions(None, 50, None, 60, None, None) + assert result is not None + assert "dangerous winds" in result.lower() + + def test_high_wind(self): + result = _driving_from_conditions(None, 35, None, 60, None, None) + assert result is not None + assert "high winds" in result.lower() + + def test_gusty_wind(self): + result = _driving_from_conditions(None, 22, None, 60, None, None) + assert result is not None + assert "gusty winds" in result.lower() + + def test_calm_wind_no_mention(self): + result = _driving_from_conditions(None, 10, None, 60, None, None) + assert result is not None + assert "wind" not in result.lower() + + def test_gust_used_when_higher_than_sustained(self): + # sustained = 10 mph, gust = 50 mph → should trigger dangerous + result = _driving_from_conditions(None, 10, 50, 60, None, None) + assert result is not None + assert "dangerous winds" in result.lower() + + def test_boundary_exactly_45(self): + result = _driving_from_conditions(None, 45, None, 60, None, None) + assert result is not None + assert "dangerous winds" in result.lower() + + def test_boundary_exactly_30(self): + result = _driving_from_conditions(None, 30, None, 60, None, None) + assert result is not None + assert "high winds" in result.lower() + + def test_boundary_exactly_20(self): + result = _driving_from_conditions(None, 20, None, 60, None, None) + assert result is not None + assert "gusty winds" in result.lower() + + def test_none_wind_no_mention(self): + result = _driving_from_conditions(None, None, None, 60, None, None) + assert result is not None + assert "wind" not in result.lower() + + +class TestDrivingNormalConditions: + def test_no_hazards_returns_normal(self): + result = _driving_from_conditions(10.0, 5.0, None, 65, "Sunny", None) + assert result == "Normal driving conditions" + + def test_all_none_returns_normal(self): + result = _driving_from_conditions(None, None, None, None, None, None) + assert result == "Normal driving conditions" + + def test_caution_prefix_when_issues(self): + result = _driving_from_conditions(0.1, None, None, 50, None, None) + assert result is not None + assert result.startswith("Caution:") + + +class TestDrivingMultipleIssues: + def test_visibility_and_wind_combined(self): + result = _driving_from_conditions(0.5, 50, None, 65, None, None) + assert result is not None + assert "very low visibility" in result.lower() + assert "dangerous winds" in result.lower() + + def test_ice_and_wind_combined(self): + result = _driving_from_conditions(None, 35, None, 30, "Freezing Rain", None) + assert result is not None + assert "ice" in result.lower() + assert "high winds" in result.lower() + + +# ── Allergy guidance ────────────────────────────────────────────────────────── + + +class TestAllergyPollenCategory: + @pytest.mark.parametrize( + "category, fragment", + [ + ("Extreme", "very high pollen"), + ("Very High", "very high pollen"), + ("High", "high pollen"), + ("Moderate", "moderate pollen"), + ("Low", "low pollen"), + ("Very Low", "low pollen"), + ], + ) + def test_pollen_categories(self, category, fragment): + result = _allergy_from_conditions(None, category, None, None, None) + assert result is not None + assert fragment in result.lower() + + def test_none_category_returns_none_when_no_index(self): + result = _allergy_from_conditions(None, None, None, None, None) + assert result is None + + def test_allergen_name_appended(self): + result = _allergy_from_conditions(None, "High", "Oak", None, None) + assert result is not None + assert "oak" in result.lower() + + def test_allergen_appended_to_very_high(self): + result = _allergy_from_conditions(None, "Very High", "Grass", None, None) + assert result is not None + assert "grass" in result.lower() + + def test_allergen_appended_to_moderate(self): + result = _allergy_from_conditions(None, "Moderate", "Ragweed", None, None) + assert result is not None + assert "ragweed" in result.lower() + + def test_none_pollen_category_no_allergen_note(self): + # No pollen data at all → returns None + result = _allergy_from_conditions(None, None, "Oak", None, None) + assert result is None + + +class TestAllergyWindDispersion: + def test_moderate_pollen_with_wind_adds_note(self): + result = _allergy_from_conditions(None, "Moderate", None, 20, None) + assert result is not None + assert "wind" in result.lower() + + def test_high_pollen_with_wind_adds_note(self): + result = _allergy_from_conditions(None, "High", None, 20, None) + assert result is not None + assert "wind" in result.lower() + + def test_low_pollen_wind_no_dispersion_note(self): + result = _allergy_from_conditions(None, "Low", None, 25, None) + assert result is not None + assert "wind" not in result.lower() + + def test_calm_wind_no_dispersion_note(self): + result = _allergy_from_conditions(None, "High", None, 10, None) + assert result is not None + assert "wind" not in result.lower() + + def test_boundary_exactly_15mph_triggers_note(self): + result = _allergy_from_conditions(None, "High", None, 15, None) + assert result is not None + assert "wind" in result.lower() + + def test_wind_14mph_no_dispersion(self): + result = _allergy_from_conditions(None, "High", None, 14, None) + assert result is not None + assert "wind" not in result.lower() + + +class TestAllergyAirQuality: + def test_unhealthy_aq_adds_note(self): + result = _allergy_from_conditions(None, None, None, None, "Unhealthy") + assert result is not None + assert "limit outdoor exposure" in result.lower() + + def test_very_unhealthy_aq_adds_note(self): + result = _allergy_from_conditions(None, None, None, None, "Very Unhealthy") + assert result is not None + assert "limit outdoor exposure" in result.lower() + + def test_hazardous_aq_adds_note(self): + result = _allergy_from_conditions(None, None, None, None, "Hazardous") + assert result is not None + assert "limit outdoor exposure" in result.lower() + + def test_sensitive_groups_aq_adds_note(self): + result = _allergy_from_conditions(None, None, None, None, "Unhealthy for Sensitive Groups") + assert result is not None + assert "sensitive groups" in result.lower() + + def test_good_aq_no_note(self): + result = _allergy_from_conditions(None, None, None, None, "Good") + assert result is None + + def test_moderate_aq_no_note(self): + result = _allergy_from_conditions(None, None, None, None, "Moderate") + assert result is None + + def test_pollen_and_bad_aq_combined(self): + result = _allergy_from_conditions(None, "High", None, None, "Unhealthy") + assert result is not None + assert "high pollen" in result.lower() + assert "limit outdoor exposure" in result.lower() + + +class TestAllergyPollenIndexFallback: + def test_high_pollen_index(self): + result = _allergy_from_conditions(12.0, None, None, None, None) + assert result is not None + assert "high pollen index" in result.lower() + + def test_moderate_pollen_index(self): + result = _allergy_from_conditions(7.0, None, None, None, None) + assert result is not None + assert "moderate" in result.lower() + + def test_low_pollen_index(self): + result = _allergy_from_conditions(2.0, None, None, None, None) + assert result is not None + assert "low" in result.lower() + + def test_boundary_exactly_10(self): + result = _allergy_from_conditions(10.0, None, None, None, None) + assert result is not None + assert "high pollen index" in result.lower() + + def test_boundary_exactly_5(self): + result = _allergy_from_conditions(5.0, None, None, None, None) + assert result is not None + assert "moderate" in result.lower() + + def test_category_takes_priority_over_index(self): + # When both category and index given, category should dominate + result = _allergy_from_conditions(15.0, "Low", None, None, None) + assert result is not None + assert "low pollen" in result.lower() + + +# ── build_impact_summary ────────────────────────────────────────────────────── + + +class TestBuildImpactSummary: + def test_none_current_returns_empty(self): + result = build_impact_summary(None) + assert not result.has_content() + + def test_all_fields_populated(self): + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + wind_speed_mph=5.0, + visibility_miles=10.0, + uv_index=3.0, + ) + env = EnvironmentalConditions( + pollen_index=8.0, + pollen_category="High", + pollen_primary_allergen="Grass", + air_quality_category="Good", + ) + result = build_impact_summary(current, env) + assert result.outdoor is not None + assert result.driving is not None + assert result.allergy is not None + + def test_outdoor_only_when_no_env(self): + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + ) + result = build_impact_summary(current) + assert result.outdoor is not None + assert result.allergy is None + + def test_driving_caution_icy(self): + current = CurrentConditions( + temperature_f=30.0, + condition="Freezing Rain", + wind_speed_mph=5.0, + visibility_miles=5.0, + ) + result = build_impact_summary(current) + assert result.driving is not None + assert "ice" in result.driving.lower() + + def test_allergy_high_pollen_with_wind(self): + current = CurrentConditions( + temperature_f=75.0, + wind_speed_mph=20.0, + ) + env = EnvironmentalConditions(pollen_category="High") + result = build_impact_summary(current, env) + assert result.allergy is not None + assert "high pollen" in result.allergy.lower() + assert "wind" in result.allergy.lower() + + def test_no_env_no_allergy(self): + current = CurrentConditions(temperature_f=70.0) + result = build_impact_summary(current) + assert result.allergy is None + + def test_returns_impact_summary_type(self): + current = CurrentConditions(temperature_f=70.0) + result = build_impact_summary(current) + assert isinstance(result, ImpactSummary) + + +# ── build_forecast_impact_summary ──────────────────────────────────────────── + + +class TestBuildForecastImpactSummary: + def test_basic_warm_sunny(self): + period = ForecastPeriod( + name="Today", + temperature=75, + temperature_unit="F", + short_forecast="Sunny", + wind_speed="10 mph", + ) + result = build_forecast_impact_summary(period) + assert result.outdoor is not None + assert "warm" in result.outdoor.lower() or "comfortable" in result.outdoor.lower() + assert result.driving == "Normal driving conditions" + + def test_snowy_period(self): + period = ForecastPeriod( + name="Tomorrow", + temperature=28, + temperature_unit="F", + short_forecast="Heavy Snow", + wind_speed="20 mph", + ) + result = build_forecast_impact_summary(period) + assert result.driving is not None + assert "snow" in result.driving.lower() + + def test_wind_range_uses_max(self): + period = ForecastPeriod( + name="Tonight", + temperature=50, + temperature_unit="F", + short_forecast="Windy", + wind_speed="20 to 35 mph", + ) + result = build_forecast_impact_summary(period) + assert result.driving is not None + assert "high winds" in result.driving.lower() + + def test_celsius_temperature_converted(self): + period = ForecastPeriod( + name="Today", + temperature=22, + temperature_unit="C", # 71.6 °F → comfortable + short_forecast="Clear", + ) + result = build_forecast_impact_summary(period) + assert result.outdoor is not None + assert "comfortable" in result.outdoor.lower() or "warm" in result.outdoor.lower() + + def test_pollen_forecast_field(self): + period = ForecastPeriod( + name="Today", + temperature=75, + temperature_unit="F", + short_forecast="Sunny", + pollen_forecast="High", + ) + result = build_forecast_impact_summary(period) + assert result.allergy is not None + assert "high pollen" in result.allergy.lower() + + def test_no_temperature_outdoor_none(self): + period = ForecastPeriod( + name="Unknown", + temperature=None, + short_forecast="Partly Cloudy", + ) + result = build_forecast_impact_summary(period) + assert result.outdoor is None + + def test_cold_snowy_period_outdoor_cold(self): + period = ForecastPeriod( + name="Tonight", + temperature=20, + temperature_unit="F", + short_forecast="Snow", + wind_speed="5 mph", + ) + result = build_forecast_impact_summary(period) + assert result.outdoor is not None + assert "very cold" in result.outdoor.lower() + assert result.driving is not None + assert "snow" in result.driving.lower() + + def test_dangerous_wind_forecast(self): + period = ForecastPeriod( + name="Tomorrow", + temperature=60, + temperature_unit="F", + short_forecast="Windy", + wind_speed="50 mph", + ) + result = build_forecast_impact_summary(period) + assert result.driving is not None + assert "dangerous winds" in result.driving.lower() + + def test_returns_impact_summary_type(self): + period = ForecastPeriod( + name="Today", + temperature=70, + temperature_unit="F", + short_forecast="Clear", + ) + result = build_forecast_impact_summary(period) + assert isinstance(result, ImpactSummary) + + +# ── Integration: metrics appear in CurrentConditionsPresentation ────────────── + + +class TestImpactMetricsInPresentation: + """Verify that impact metrics are wired into the presentation builder.""" + + def test_impact_metrics_in_current_conditions(self): + from accessiweather.display.presentation.current_conditions import build_current_conditions + from accessiweather.models.weather import CurrentConditions, Location + from accessiweather.utils import TemperatureUnit + + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + wind_speed_mph=5.0, + visibility_miles=10.0, + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0) + presentation = build_current_conditions(current, location, TemperatureUnit.FAHRENHEIT) + + metric_labels = [m.label for m in presentation.metrics] + assert "Impact: Outdoor" in metric_labels + assert "Impact: Driving" in metric_labels + + def test_impact_summary_attached_to_presentation(self): + from accessiweather.display.presentation.current_conditions import build_current_conditions + from accessiweather.models.weather import CurrentConditions, Location + from accessiweather.utils import TemperatureUnit + + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0) + presentation = build_current_conditions(current, location, TemperatureUnit.FAHRENHEIT) + + assert presentation.impact_summary is not None + assert presentation.impact_summary.outdoor is not None + + def test_impact_summary_in_fallback_text(self): + from accessiweather.display.presentation.current_conditions import build_current_conditions + from accessiweather.models.weather import CurrentConditions, Location + from accessiweather.utils import TemperatureUnit + + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0) + presentation = build_current_conditions(current, location, TemperatureUnit.FAHRENHEIT) + + assert "Impact: Outdoor" in presentation.fallback_text + + def test_allergy_metric_present_with_env_data(self): + from accessiweather.display.presentation.current_conditions import build_current_conditions + from accessiweather.models.weather import ( + CurrentConditions, + EnvironmentalConditions, + Location, + ) + from accessiweather.utils import TemperatureUnit + + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + wind_speed_mph=5.0, + ) + env = EnvironmentalConditions(pollen_category="High") + location = Location(name="Test City", latitude=40.0, longitude=-74.0) + presentation = build_current_conditions( + current, location, TemperatureUnit.FAHRENHEIT, environmental=env + ) + + metric_labels = [m.label for m in presentation.metrics] + assert "Impact: Allergy" in metric_labels + + def test_no_allergy_metric_without_env_data(self): + from accessiweather.display.presentation.current_conditions import build_current_conditions + from accessiweather.models.weather import CurrentConditions, Location + from accessiweather.utils import TemperatureUnit + + current = CurrentConditions( + temperature_f=72.0, + feels_like_f=72.0, + condition="Sunny", + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0) + presentation = build_current_conditions(current, location, TemperatureUnit.FAHRENHEIT) + + metric_labels = [m.label for m in presentation.metrics] + assert "Impact: Allergy" not in metric_labels + + +class TestImpactMetricsInForecastPresentation: + """Verify that forecast impact summary is populated in ForecastPresentation.""" + + def test_forecast_impact_summary_attached(self): + from datetime import UTC, datetime + + from accessiweather.display.presentation.forecast import build_forecast + from accessiweather.models.weather import Forecast, ForecastPeriod, Location + from accessiweather.utils import TemperatureUnit + + forecast = Forecast( + periods=[ + ForecastPeriod( + name="Today", + temperature=75, + temperature_unit="F", + short_forecast="Sunny", + wind_speed="10 mph", + ) + ], + generated_at=datetime.now(UTC), + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US") + presentation = build_forecast(forecast, None, location, TemperatureUnit.FAHRENHEIT) + + assert presentation.impact_summary is not None + assert presentation.impact_summary.outdoor is not None + + def test_forecast_impact_summary_none_when_no_periods(self): + from datetime import UTC, datetime + + from accessiweather.display.presentation.forecast import build_forecast + from accessiweather.models.weather import Forecast, Location + from accessiweather.utils import TemperatureUnit + + forecast = Forecast( + periods=[], + generated_at=datetime.now(UTC), + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US") + presentation = build_forecast(forecast, None, location, TemperatureUnit.FAHRENHEIT) + + assert presentation.impact_summary is None + + def test_forecast_impact_driving_icy(self): + from datetime import UTC, datetime + + from accessiweather.display.presentation.forecast import build_forecast + from accessiweather.models.weather import Forecast, ForecastPeriod, Location + from accessiweather.utils import TemperatureUnit + + forecast = Forecast( + periods=[ + ForecastPeriod( + name="Tomorrow", + temperature=28, + temperature_unit="F", + short_forecast="Freezing Rain", + wind_speed="10 mph", + ) + ], + generated_at=datetime.now(UTC), + ) + location = Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US") + presentation = build_forecast(forecast, None, location, TemperatureUnit.FAHRENHEIT) + + assert presentation.impact_summary is not None + assert presentation.impact_summary.driving is not None + assert "ice" in presentation.impact_summary.driving.lower()