diff --git a/src/accessiweather/config/config_manager.py b/src/accessiweather/config/config_manager.py
index 4fa5920b..17dfe537 100644
--- a/src/accessiweather/config/config_manager.py
+++ b/src/accessiweather/config/config_manager.py
@@ -278,10 +278,21 @@ def update_settings(self, **kwargs) -> bool:
return self._settings.update_settings(**kwargs)
def add_location(
- self, name: str, latitude: float, longitude: float, country_code: str | None = None
+ self,
+ name: str,
+ latitude: float,
+ longitude: float,
+ country_code: str | None = None,
+ marine_mode: bool = False,
) -> bool:
"""Add a new location."""
- return self._locations.add_location(name, latitude, longitude, country_code)
+ return self._locations.add_location(
+ name,
+ latitude,
+ longitude,
+ country_code,
+ marine_mode=marine_mode,
+ )
def remove_location(self, name: str) -> bool:
"""Remove a location."""
diff --git a/src/accessiweather/config/locations.py b/src/accessiweather/config/locations.py
index 764c3e28..9a5dd61a 100644
--- a/src/accessiweather/config/locations.py
+++ b/src/accessiweather/config/locations.py
@@ -30,6 +30,7 @@ def add_location(
latitude: float,
longitude: float,
country_code: str | None = None,
+ marine_mode: bool = False,
) -> bool:
"""Add a new location if it doesn't already exist."""
config = self._manager.get_config()
@@ -44,6 +45,7 @@ def add_location(
latitude=latitude,
longitude=longitude,
country_code=country_code,
+ marine_mode=marine_mode,
)
config.locations.append(new_location)
diff --git a/src/accessiweather/display/presentation/forecast.py b/src/accessiweather/display/presentation/forecast.py
index 8d70b548..6607dd17 100644
--- a/src/accessiweather/display/presentation/forecast.py
+++ b/src/accessiweather/display/presentation/forecast.py
@@ -6,7 +6,14 @@
from datetime import datetime, tzinfo
from ...forecast_confidence import ForecastConfidence
-from ...models import AppSettings, Forecast, ForecastPeriod, HourlyForecast, Location
+from ...models import (
+ AppSettings,
+ Forecast,
+ ForecastPeriod,
+ HourlyForecast,
+ Location,
+ MarineForecast,
+)
from ...utils import TemperatureUnit, calculate_dewpoint
from ...utils.unit_utils import format_precipitation, format_wind_speed
from ..weather_presenter import (
@@ -105,6 +112,7 @@ def build_forecast(
unit_pref: TemperatureUnit,
settings: AppSettings | None = None,
*,
+ marine: MarineForecast | None = None,
confidence: ForecastConfidence | None = None,
) -> ForecastPresentation:
"""Create a structured forecast including optional hourly highlights."""
@@ -277,7 +285,32 @@ def build_forecast(
hours=hourly_hours,
summary_line=hourly_summary_line,
)
+
+ marine_summary = None
+ marine_highlights: list[str] = []
+ marine_section_text = ""
+ if marine and marine.has_data():
+ marine_lines = [f"Marine conditions for {location.name}:"]
+ if marine.zone_name:
+ zone_label = marine.zone_name
+ if marine.zone_id:
+ zone_label = f"{zone_label} ({marine.zone_id})"
+ marine_lines.append(f"Marine zone: {zone_label}")
+ marine_summary = marine.forecast_summary
+ if marine_summary:
+ marine_lines.append(f"Summary: {marine_summary}")
+ marine_highlights = marine.highlights[:4]
+ if marine_highlights:
+ marine_lines.append("Wind and wave highlights:")
+ marine_lines.extend(f" • {highlight}" for highlight in marine_highlights)
+ for period in marine.periods[:3]:
+ if period.summary:
+ marine_lines.append(f"{period.name}: {wrap_text(period.summary, 80)}")
+ marine_section_text = "\n".join(marine_lines).rstrip()
+
fallback_sections = [daily_section_text]
+ if marine_section_text:
+ fallback_sections.append(marine_section_text)
if hourly_section_text:
fallback_sections.append(hourly_section_text)
fallback_text = "\n\n".join(section for section in fallback_sections if section).rstrip()
@@ -291,6 +324,9 @@ def build_forecast(
fallback_text=fallback_text,
daily_section_text=daily_section_text,
hourly_section_text=hourly_section_text,
+ marine_section_text=marine_section_text,
+ marine_summary=marine_summary,
+ marine_highlights=marine_highlights,
confidence_label=confidence_label,
summary=summary_line,
)
diff --git a/src/accessiweather/display/presentation/html_formatters.py b/src/accessiweather/display/presentation/html_formatters.py
index 92194abf..60eab97e 100644
--- a/src/accessiweather/display/presentation/html_formatters.py
+++ b/src/accessiweather/display/presentation/html_formatters.py
@@ -133,6 +133,23 @@
color: #888;
font-style: italic;
}
+.marine-section {
+ margin-bottom: 16px;
+ padding: 8px;
+ background-color: #f3f8ff;
+ border-radius: 4px;
+ border: 1px solid #d7e7ff;
+}
+.marine-section ul {
+ margin: 6px 0 0 18px;
+ padding: 0;
+}
+.marine-section li {
+ margin-bottom: 4px;
+}
+.marine-period {
+ margin-top: 8px;
+}
"""
@@ -259,6 +276,54 @@ def generate_forecast_html(presentation: ForecastPresentation | None) -> str:
{"".join(hourly_items)}
+"""
+
+ # Build marine section if available
+ marine_html = ""
+ if presentation.marine_section_text:
+ marine_summary_html = ""
+ if presentation.marine_summary:
+ marine_summary_html = (
+ f'{_escape_html(presentation.marine_summary)}
'
+ )
+
+ marine_highlights_html = ""
+ if presentation.marine_highlights:
+ marine_items = "".join(
+ f"{_escape_html(highlight)}"
+ for highlight in presentation.marine_highlights
+ )
+ marine_highlights_html = f"""
+Marine Highlights
+"""
+
+ period_lines = [
+ line.strip()
+ for line in presentation.marine_section_text.splitlines()
+ if line.strip()
+ and ":" in line
+ and not line.startswith(
+ (
+ "Marine conditions",
+ "Marine zone:",
+ "Summary:",
+ "Wind and wave highlights:",
+ "•",
+ )
+ )
+ ]
+ marine_periods_html = "".join(
+ f'{_escape_html(line)}
' for line in period_lines
+ )
+
+ marine_html = f"""
+
+Marine Forecast
+{marine_summary_html}
+{marine_highlights_html}
+{marine_periods_html}
"""
# Build forecast periods
@@ -295,6 +360,7 @@ def generate_forecast_html(presentation: ForecastPresentation | None) -> str:
{title}
{hourly_html}
+{marine_html}
{periods_html}
{generated_html}
diff --git a/src/accessiweather/display/weather_presenter.py b/src/accessiweather/display/weather_presenter.py
index 15a2bf4d..0448054c 100644
--- a/src/accessiweather/display/weather_presenter.py
+++ b/src/accessiweather/display/weather_presenter.py
@@ -25,6 +25,7 @@
Forecast,
HourlyForecast,
Location,
+ MarineForecast,
TrendInsight,
WeatherAlerts,
WeatherData,
@@ -105,6 +106,9 @@ class ForecastPresentation:
fallback_text: str = ""
daily_section_text: str = ""
hourly_section_text: str = ""
+ marine_section_text: str = ""
+ marine_summary: str | None = None
+ marine_highlights: list[str] = field(default_factory=list)
confidence_label: str | None = None
summary: str | None = None
@@ -233,6 +237,7 @@ def present(self, weather_data: WeatherData) -> WeatherPresentation:
weather_data.hourly_forecast,
weather_data.location,
unit_pref,
+ marine=weather_data.marine,
confidence=weather_data.forecast_confidence,
)
if weather_data.forecast
@@ -308,13 +313,19 @@ def present_forecast(
forecast: Forecast | None,
location: Location,
hourly_forecast: HourlyForecast | None = None,
+ marine: MarineForecast | None = None,
confidence: ForecastConfidence | None = None,
) -> ForecastPresentation | None:
if not forecast or not forecast.has_data():
return None
unit_pref, _unit_system = self._resolve_unit_preferences(location)
return self._build_forecast(
- forecast, hourly_forecast, location, unit_pref, confidence=confidence
+ forecast,
+ hourly_forecast,
+ location,
+ unit_pref,
+ marine=marine,
+ confidence=confidence,
)
def present_alerts(
@@ -365,6 +376,7 @@ def _build_forecast(
hourly_forecast: HourlyForecast | None,
location: Location,
unit_pref: TemperatureUnit,
+ marine: MarineForecast | None = None,
confidence: ForecastConfidence | None = None,
) -> ForecastPresentation:
return build_forecast(
@@ -373,6 +385,7 @@ def _build_forecast(
location,
unit_pref,
settings=self.settings,
+ marine=marine,
confidence=confidence,
)
diff --git a/src/accessiweather/models/__init__.py b/src/accessiweather/models/__init__.py
index 12a1b39c..fc575f76 100644
--- a/src/accessiweather/models/__init__.py
+++ b/src/accessiweather/models/__init__.py
@@ -25,6 +25,8 @@
HourlyForecastPeriod,
HourlyUVIndex,
Location,
+ MarineForecast,
+ MarineForecastPeriod,
MinutelyPrecipitationForecast,
MinutelyPrecipitationPoint,
SourceAttribution,
@@ -42,6 +44,8 @@
"HourlyForecast",
"HourlyAirQuality",
"HourlyUVIndex",
+ "MarineForecastPeriod",
+ "MarineForecast",
"MinutelyPrecipitationPoint",
"MinutelyPrecipitationForecast",
"TrendInsight",
diff --git a/src/accessiweather/models/config.py b/src/accessiweather/models/config.py
index 269e2ff0..3aaa8e14 100644
--- a/src/accessiweather/models/config.py
+++ b/src/accessiweather/models/config.py
@@ -636,6 +636,7 @@ def to_dict(self) -> dict:
"latitude": loc.latitude,
"longitude": loc.longitude,
**({"country_code": loc.country_code} if loc.country_code else {}),
+ **({"marine_mode": True} if loc.marine_mode else {}),
}
for loc in self.locations
],
@@ -648,6 +649,7 @@ def to_dict(self) -> dict:
if self.current_location.country_code
else {}
),
+ **({"marine_mode": True} if self.current_location.marine_mode else {}),
}
if self.current_location
else None,
@@ -666,6 +668,7 @@ def from_dict(cls, data: dict) -> AppConfig:
latitude=loc_data["latitude"],
longitude=loc_data["longitude"],
country_code=loc_data.get("country_code"),
+ marine_mode=bool(loc_data.get("marine_mode", False)),
)
)
@@ -677,6 +680,7 @@ def from_dict(cls, data: dict) -> AppConfig:
latitude=loc_data["latitude"],
longitude=loc_data["longitude"],
country_code=loc_data.get("country_code"),
+ marine_mode=bool(loc_data.get("marine_mode", False)),
)
return cls(
diff --git a/src/accessiweather/models/weather.py b/src/accessiweather/models/weather.py
index 222b3ffe..318ae2b0 100644
--- a/src/accessiweather/models/weather.py
+++ b/src/accessiweather/models/weather.py
@@ -130,6 +130,7 @@ class Location:
longitude: float
timezone: str | None = None
country_code: str | None = None
+ marine_mode: bool = False
def __str__(self) -> str:
return self.name
@@ -532,6 +533,30 @@ def has_taf(self) -> bool:
)
+@dataclass
+class MarineForecastPeriod:
+ """Single marine forecast period from an NWS marine zone product."""
+
+ name: str
+ summary: str
+
+
+@dataclass
+class MarineForecast:
+ """Marine zone essentials for a coastal location."""
+
+ zone_id: str | None = None
+ zone_name: str | None = None
+ forecast_summary: str | None = None
+ issued_at: datetime | None = None
+ periods: list[MarineForecastPeriod] = field(default_factory=list)
+ highlights: list[str] = field(default_factory=list)
+
+ def has_data(self) -> bool:
+ """Return True when any marine essentials are available."""
+ return bool(self.forecast_summary or self.zone_name or self.periods or self.highlights)
+
+
@dataclass
class WeatherData:
"""Complete weather data for a location."""
@@ -547,6 +572,7 @@ class WeatherData:
alerts: WeatherAlerts | None = None
environmental: EnvironmentalConditions | None = None
aviation: AviationData | None = None
+ marine: MarineForecast | None = None
trend_insights: list[TrendInsight] = field(default_factory=list)
stale: bool = False
stale_since: datetime | None = None
@@ -584,5 +610,6 @@ def has_any_data(self) -> bool:
self.alerts and self.alerts.has_alerts(),
self.environmental and self.environmental.has_data(),
self.aviation and self.aviation.has_taf(),
+ self.marine and self.marine.has_data(),
]
)
diff --git a/src/accessiweather/ui/dialogs/location_dialog.py b/src/accessiweather/ui/dialogs/location_dialog.py
index 8ed0e70a..77fac504 100644
--- a/src/accessiweather/ui/dialogs/location_dialog.py
+++ b/src/accessiweather/ui/dialogs/location_dialog.py
@@ -107,6 +107,15 @@ def _create_ui(self):
help_text.SetForegroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_GRAYTEXT))
name_sizer.Add(help_text, 0, wx.TOP, 5)
+ self.marine_mode_checkbox = wx.CheckBox(
+ panel,
+ label="Enable Marine Mode for this location (coastal essentials only)",
+ )
+ self.marine_mode_checkbox.SetToolTip(
+ "Adds nearby NWS marine zone summary, wind and wave highlights, and marine advisories."
+ )
+ name_sizer.Add(self.marine_mode_checkbox, 0, wx.TOP, 8)
+
main_sizer.Add(name_sizer, 0, wx.EXPAND | wx.ALL, 10)
# Search section
@@ -174,6 +183,7 @@ def _setup_accessibility(self):
self.name_input.SetName("Location name input")
self.search_input.SetName("Search for location")
self.results_list.SetName("Search results")
+ self.marine_mode_checkbox.SetName("Enable Marine Mode for this location")
def _on_key(self, event: wx.KeyEvent) -> None:
"""Handle key events."""
@@ -307,7 +317,11 @@ def _on_save(self, event):
# Add location
success = self.config_manager.add_location(
- name, latitude, longitude, country_code=country_code
+ name,
+ latitude,
+ longitude,
+ country_code=country_code,
+ marine_mode=self.marine_mode_checkbox.GetValue(),
)
if success:
diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py
index 55123e60..91dd725c 100644
--- a/src/accessiweather/ui/main_window.py
+++ b/src/accessiweather/ui/main_window.py
@@ -1201,9 +1201,11 @@ def _on_weather_data_received(self, weather_data) -> None:
# Update forecast
if presentation.forecast:
- daily_text = (
- presentation.forecast.daily_section_text or "No daily forecast available."
- )
+ daily_sections = [presentation.forecast.daily_section_text]
+ if presentation.forecast.marine_section_text:
+ daily_sections.append(presentation.forecast.marine_section_text)
+ daily_text = "\n\n".join(section for section in daily_sections if section).rstrip()
+ daily_text = daily_text or "No daily forecast available."
hourly_text = (
presentation.forecast.hourly_section_text or "No hourly forecast available."
)
diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py
index da424a05..c23dcab4 100644
--- a/src/accessiweather/weather_client_base.py
+++ b/src/accessiweather/weather_client_base.py
@@ -1174,6 +1174,9 @@ def _launch_enrichment_tasks(
tasks["aviation"] = asyncio.create_task(
enrichment.enrich_with_aviation_data(self, weather_data, location)
)
+ tasks["marine"] = asyncio.create_task(
+ enrichment.enrich_with_marine_data(self, weather_data, location)
+ )
return tasks
diff --git a/src/accessiweather/weather_client_enrichment.py b/src/accessiweather/weather_client_enrichment.py
index f997b117..827c7f17 100644
--- a/src/accessiweather/weather_client_enrichment.py
+++ b/src/accessiweather/weather_client_enrichment.py
@@ -3,12 +3,22 @@
from __future__ import annotations
import logging
+import re
+from datetime import datetime
from typing import TYPE_CHECKING, Any
from . import weather_client_nws as nws_client
from .api.avwx_client import AvwxApiError, fetch_avwx_taf, is_us_station
from .display.presentation.environmental import _get_uv_category
-from .models import AviationData, Location, WeatherAlert, WeatherAlerts, WeatherData
+from .models import (
+ AviationData,
+ Location,
+ MarineForecast,
+ MarineForecastPeriod,
+ WeatherAlert,
+ WeatherAlerts,
+ WeatherData,
+)
from .utils import decode_taf_text
if TYPE_CHECKING:
@@ -291,6 +301,132 @@ async def get_aviation_weather(
return aviation
+_MARINE_HIGHLIGHT_PATTERN = re.compile(
+ r"([^.;]*\b(?:wind|winds|gust|gusts|wave|waves|seas|swell|swells)\b[^.;]*)",
+ re.IGNORECASE,
+)
+
+
+def _build_marine_highlights(periods: list[dict[str, Any]]) -> list[str]:
+ """Extract concise wind and wave highlights from marine text periods."""
+ highlights: list[str] = []
+ seen: set[str] = set()
+ for period in periods:
+ for field in ("shortForecast", "detailedForecast"):
+ text = str(period.get(field) or "").strip()
+ if not text:
+ continue
+ for match in _MARINE_HIGHLIGHT_PATTERN.findall(text):
+ candidate = " ".join(match.split()).strip(" .")
+ if not candidate:
+ continue
+ normalized = candidate.lower()
+ if normalized in seen:
+ continue
+ seen.add(normalized)
+ highlights.append(candidate)
+ if len(highlights) >= 4:
+ return highlights
+ return highlights
+
+
+def _parse_marine_issued_at(value: Any) -> datetime | None:
+ if not value or not isinstance(value, str):
+ return None
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+
+async def enrich_with_marine_data(
+ client: WeatherClient, weather_data: WeatherData, location: Location
+) -> None:
+ """Populate marine essentials for coastal locations when marine mode is enabled."""
+ if not getattr(location, "marine_mode", False) or not client._is_us_location(location):
+ return
+
+ try:
+ http_client = client._get_http_client()
+ headers = {"User-Agent": client.user_agent}
+ marine_zone_response = await nws_client._client_get(
+ http_client,
+ f"{client.nws_base_url}/zones",
+ headers=headers,
+ params={"type": "marine", "point": f"{location.latitude},{location.longitude}"},
+ )
+ marine_zone_response.raise_for_status()
+ marine_zone_data = marine_zone_response.json()
+ features = marine_zone_data.get("features") or []
+ if not features:
+ return
+
+ marine_feature = features[0]
+ marine_properties = marine_feature.get("properties", {})
+ zone_id = marine_properties.get("id") or marine_feature.get("id")
+ if not zone_id:
+ return
+
+ marine_forecast_data = await nws_client.get_nws_marine_forecast(
+ "marine",
+ zone_id,
+ client.nws_base_url,
+ client.user_agent,
+ client.timeout,
+ http_client,
+ )
+ if not marine_forecast_data:
+ return
+
+ forecast_properties = marine_forecast_data.get("properties", {})
+ forecast_periods = forecast_properties.get("periods") or []
+ periods = [
+ MarineForecastPeriod(
+ name=str(period.get("name") or "Marine period"),
+ summary=str(
+ period.get("detailedForecast") or period.get("shortForecast") or ""
+ ).strip(),
+ )
+ for period in forecast_periods[:3]
+ if (period.get("detailedForecast") or period.get("shortForecast"))
+ ]
+ marine = MarineForecast(
+ zone_id=zone_id,
+ zone_name=forecast_properties.get("name") or marine_properties.get("name"),
+ forecast_summary=(
+ str(
+ forecast_periods[0].get("detailedForecast")
+ or forecast_periods[0].get("shortForecast")
+ )
+ if forecast_periods
+ else None
+ ),
+ issued_at=_parse_marine_issued_at(forecast_properties.get("updateTime")),
+ periods=periods,
+ highlights=_build_marine_highlights(forecast_periods[:4]),
+ )
+ if marine.has_data():
+ weather_data.marine = marine
+
+ marine_alerts_response = await nws_client._client_get(
+ http_client,
+ f"{client.nws_base_url}/alerts/active",
+ headers=headers,
+ params={"zone": zone_id, "status": "actual"},
+ )
+ marine_alerts_response.raise_for_status()
+ marine_alerts = nws_client.parse_nws_alerts(marine_alerts_response.json())
+ if marine_alerts and marine_alerts.alerts:
+ existing = weather_data.alerts.alerts if weather_data.alerts else []
+ merged: dict[str, WeatherAlert] = {alert.get_unique_id(): alert for alert in existing}
+ for alert in marine_alerts.alerts:
+ alert.source = "NWS Marine"
+ merged.setdefault(alert.get_unique_id(), alert)
+ weather_data.alerts = WeatherAlerts(alerts=list(merged.values()))
+ except Exception as exc: # noqa: BLE001
+ logger.debug("Failed to fetch marine essentials for %s: %s", location.name, exc)
+
+
async def enrich_with_aviation_data(
client: WeatherClient, weather_data: WeatherData, location: Location
) -> None:
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
index bdd1e666..38e04717 100644
--- a/tests/test_config_manager.py
+++ b/tests/test_config_manager.py
@@ -83,6 +83,29 @@ def test_add_location(self, manager):
assert len(non_nationwide) == 1
assert non_nationwide[0].name == "New York"
+ def test_add_location_persists_marine_mode_roundtrip(self, manager):
+ """Marine mode should save and reload with locations."""
+ result = manager.add_location(
+ name="Annapolis",
+ latitude=38.9784,
+ longitude=-76.4922,
+ country_code="US",
+ marine_mode=True,
+ )
+ assert result is True
+
+ manager.set_current_location("Annapolis")
+ manager.save_config()
+
+ manager2 = ConfigManager(manager.app, config_dir=manager.config_dir)
+ loaded = manager2.load_config()
+
+ saved_location = next(loc for loc in loaded.locations if loc.name == "Annapolis")
+ assert saved_location.marine_mode is True
+ assert loaded.current_location is not None
+ assert loaded.current_location.name == "Annapolis"
+ assert loaded.current_location.marine_mode is True
+
def test_add_duplicate_location_fails(self, manager):
"""Test that adding duplicate location fails."""
manager.add_location("Test", 40.0, -74.0)
diff --git a/tests/test_coverage_fix_pr449.py b/tests/test_coverage_fix_pr449.py
index 913de405..2d135442 100644
--- a/tests/test_coverage_fix_pr449.py
+++ b/tests/test_coverage_fix_pr449.py
@@ -110,11 +110,11 @@ def test_parse_nws_alerts_extracts_references():
# ---------------------------------------------------------------------------
# 4. weather_client_base.py lines 912-924: _launch_enrichment_tasks auto-mode
# These lines are inside `if self.data_source == "auto":` and create tasks
-# for sunrise_sunset, nws_discussion, vc_alerts, and vc_moon_data.
+# for sunrise_sunset, nws_discussion, vc_alerts, vc_moon_data, and marine.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_launch_enrichment_tasks_auto_mode_creates_smart_tasks():
- """_launch_enrichment_tasks with data_source='auto' creates all four smart enrichment tasks."""
+ """_launch_enrichment_tasks with data_source='auto' creates all smart enrichment tasks."""
import asyncio
import accessiweather.weather_client_enrichment as enrichment
@@ -135,6 +135,7 @@ async def _noop(*args, **kwargs):
patch.object(enrichment, "enrich_with_visual_crossing_moon_data", side_effect=_noop),
patch.object(enrichment, "populate_environmental_metrics", side_effect=_noop),
patch.object(enrichment, "enrich_with_aviation_data", side_effect=_noop),
+ patch.object(enrichment, "enrich_with_marine_data", side_effect=_noop),
):
tasks = client._launch_enrichment_tasks(weather_data, location)
# Cancel tasks to prevent ResourceWarning about pending coroutines
@@ -148,6 +149,7 @@ async def _noop(*args, **kwargs):
assert "vc_moon_data" in tasks
assert "environmental" in tasks
assert "aviation" in tasks
+ assert "marine" in tasks
@pytest.mark.asyncio
@@ -169,6 +171,7 @@ async def _noop(*args, **kwargs):
with (
patch.object(enrichment, "populate_environmental_metrics", side_effect=_noop),
patch.object(enrichment, "enrich_with_aviation_data", side_effect=_noop),
+ patch.object(enrichment, "enrich_with_marine_data", side_effect=_noop),
):
tasks = client._launch_enrichment_tasks(weather_data, location)
for t in tasks.values():
@@ -181,3 +184,4 @@ async def _noop(*args, **kwargs):
assert "vc_moon_data" not in tasks
assert "environmental" in tasks
assert "aviation" in tasks
+ assert "marine" in tasks
diff --git a/tests/test_hourly_forecast_presentation.py b/tests/test_hourly_forecast_presentation.py
index 5078a4af..fb3c2190 100644
--- a/tests/test_hourly_forecast_presentation.py
+++ b/tests/test_hourly_forecast_presentation.py
@@ -15,6 +15,8 @@
HourlyForecast,
HourlyForecastPeriod,
Location,
+ MarineForecast,
+ MarineForecastPeriod,
)
from accessiweather.utils import TemperatureUnit
@@ -115,3 +117,48 @@ def test_build_forecast_exposes_daily_and_hourly_sections():
assert "Hourly outlook: Clear through mid afternoon." in result.hourly_section_text
assert "Next 1 Hours:" in result.hourly_section_text
assert result.fallback_text == (f"{result.daily_section_text}\n\n{result.hourly_section_text}")
+
+
+def test_build_forecast_includes_marine_section_in_fallback_text():
+ forecast = Forecast(
+ periods=[
+ ForecastPeriod(
+ name="Today",
+ temperature=70.0,
+ temperature_low=54.0,
+ temperature_unit="F",
+ short_forecast="Sunny",
+ )
+ ]
+ )
+ marine = MarineForecast(
+ zone_id="ANZ530",
+ zone_name="Chesapeake Bay from Pooles Island to Sandy Point",
+ forecast_summary="South winds 10 to 15 knots with waves 1 to 2 feet.",
+ highlights=["South winds 10 to 15 knots", "Waves 1 to 2 feet"],
+ periods=[
+ MarineForecastPeriod(
+ name="Tonight",
+ summary="South winds 10 to 15 knots with waves 1 to 2 feet.",
+ )
+ ],
+ )
+
+ result = build_forecast(
+ forecast,
+ None,
+ Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True),
+ TemperatureUnit.FAHRENHEIT,
+ settings=AppSettings(),
+ marine=marine,
+ )
+
+ assert result.marine_summary == marine.forecast_summary
+ assert result.marine_highlights == marine.highlights
+ assert "Marine conditions for Annapolis:" in result.marine_section_text
+ assert (
+ "Marine zone: Chesapeake Bay from Pooles Island to Sandy Point (ANZ530)"
+ in result.marine_section_text
+ )
+ assert "Wind and wave highlights:" in result.marine_section_text
+ assert result.fallback_text == (f"{result.daily_section_text}\n\n{result.marine_section_text}")
diff --git a/tests/test_html_formatters.py b/tests/test_html_formatters.py
index 9029d273..c8b572e7 100644
--- a/tests/test_html_formatters.py
+++ b/tests/test_html_formatters.py
@@ -231,6 +231,27 @@ def test_with_generated_at(self):
assert "Forecast generated: 2026-02-14 02:00 UTC" in html
assert "generated-at" in html
+ def test_with_marine_section(self):
+ pres = ForecastPresentation(
+ title="Forecast",
+ marine_section_text=(
+ "Marine conditions for Annapolis:\n"
+ "Marine zone: Chesapeake Bay (ANZ530)\n"
+ "Summary: South winds 10 to 15 knots with waves 1 to 2 feet.\n"
+ "Wind and wave highlights:\n"
+ " • South winds 10 to 15 knots\n"
+ "Tonight: South winds 10 to 15 knots with waves 1 to 2 feet."
+ ),
+ marine_summary="South winds 10 to 15 knots with waves 1 to 2 feet.",
+ marine_highlights=["South winds 10 to 15 knots", "Waves 1 to 2 feet"],
+ )
+ html = generate_forecast_html(pres)
+ assert 'aria-label="Marine forecast"' in html
+ assert "Marine Forecast" in html
+ assert "Marine Highlights" in html
+ assert "South winds 10 to 15 knots" in html
+ assert "Tonight: South winds 10 to 15 knots with waves 1 to 2 feet." in html
+
def test_no_generated_at(self):
pres = ForecastPresentation(title="F")
html = generate_forecast_html(pres)
diff --git a/tests/test_main_window_forecast_sections.py b/tests/test_main_window_forecast_sections.py
index a832136a..ee12fe5c 100644
--- a/tests/test_main_window_forecast_sections.py
+++ b/tests/test_main_window_forecast_sections.py
@@ -64,3 +64,23 @@ def test_set_forecast_sections_updates_both_controls():
win.daily_forecast_display.SetValue.assert_called_with("Daily text")
win.hourly_forecast_display.SetValue.assert_called_with("Hourly text")
+
+
+def test_on_weather_data_received_appends_marine_to_daily_forecast_section():
+ win = _make_window()
+ win.app.presenter.present.return_value.forecast = ForecastPresentation(
+ title="Forecast",
+ fallback_text="Combined forecast",
+ daily_section_text="Daily section",
+ marine_section_text="Marine section",
+ hourly_section_text="Hourly section",
+ )
+
+ weather_data = MagicMock()
+ weather_data.alerts = None
+ weather_data.alert_lifecycle_diff = None
+
+ win._on_weather_data_received(weather_data)
+
+ win.daily_forecast_display.SetValue.assert_called_once_with("Daily section\n\nMarine section")
+ win.hourly_forecast_display.SetValue.assert_called_once_with("Hourly section")
diff --git a/tests/test_marine_enrichment.py b/tests/test_marine_enrichment.py
new file mode 100644
index 00000000..6f2b8423
--- /dev/null
+++ b/tests/test_marine_enrichment.py
@@ -0,0 +1,196 @@
+"""Tests for marine enrichment helpers and marine mode NWS enrichment."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from accessiweather.models import Location, WeatherAlert, WeatherAlerts, WeatherData
+from accessiweather.weather_client_enrichment import (
+ _build_marine_highlights,
+ _parse_marine_issued_at,
+ enrich_with_marine_data,
+)
+
+
+class _MockResponse:
+ def __init__(self, payload: dict):
+ self._payload = payload
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> dict:
+ return self._payload
+
+
+def test_build_marine_highlights_deduplicates_and_limits_results():
+ periods = [
+ {
+ "shortForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.",
+ "detailedForecast": "South winds 10 to 15 knots. Seas around 2 feet.",
+ },
+ {
+ "shortForecast": "Gusts up to 20 knots. Swells 3 feet.",
+ "detailedForecast": "Gusts up to 20 knots. Swells 3 feet.",
+ },
+ {"shortForecast": "North winds 5 knots.", "detailedForecast": ""},
+ ]
+
+ highlights = _build_marine_highlights(periods)
+
+ assert highlights == [
+ "South winds 10 to 15 knots",
+ "Waves 1 to 2 feet",
+ "Seas around 2 feet",
+ "Gusts up to 20 knots",
+ ]
+
+
+@pytest.mark.parametrize(
+ ("value", "expected_none"),
+ [
+ ("2026-03-27T15:00:00Z", False),
+ ("not-a-date", True),
+ (None, True),
+ ],
+)
+def test_parse_marine_issued_at_handles_valid_and_invalid_values(value, expected_none):
+ result = _parse_marine_issued_at(value)
+
+ assert (result is None) is expected_none
+
+
+@pytest.mark.asyncio
+async def test_enrich_with_marine_data_noops_when_marine_mode_disabled():
+ location = Location(name="Inland", latitude=39.0, longitude=-76.0, marine_mode=False)
+ weather_data = WeatherData(location=location)
+ client = MagicMock()
+ client._is_us_location.return_value = True
+
+ await enrich_with_marine_data(client, weather_data, location)
+
+ client._get_http_client.assert_not_called()
+ assert weather_data.marine is None
+
+
+@pytest.mark.asyncio
+async def test_enrich_with_marine_data_populates_forecast_and_merges_alerts():
+ location = Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True)
+ weather_data = WeatherData(
+ location=location,
+ alerts=WeatherAlerts(
+ alerts=[
+ WeatherAlert(
+ id="existing-alert",
+ title="Existing Advisory",
+ description="Existing advisory",
+ )
+ ]
+ ),
+ )
+
+ client = MagicMock()
+ client._is_us_location.return_value = True
+ client._get_http_client.return_value = object()
+ client.user_agent = "AccessiWeather Test"
+ client.nws_base_url = "https://api.weather.gov"
+ client.timeout = 30
+
+ zone_response = _MockResponse(
+ {
+ "features": [
+ {
+ "id": "ANZ530",
+ "properties": {
+ "id": "ANZ530",
+ "name": "Chesapeake Bay from Pooles Island to Sandy Point",
+ },
+ }
+ ]
+ }
+ )
+ alerts_response = _MockResponse({"features": []})
+ marine_forecast = {
+ "properties": {
+ "name": "Chesapeake Bay from Pooles Island to Sandy Point",
+ "updateTime": "2026-03-27T15:00:00Z",
+ "periods": [
+ {
+ "name": "Tonight",
+ "shortForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.",
+ "detailedForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.",
+ },
+ {
+ "name": "Saturday",
+ "shortForecast": "Gusts up to 20 knots.",
+ "detailedForecast": "Gusts up to 20 knots. Seas around 2 feet.",
+ },
+ ],
+ }
+ }
+ marine_alert = WeatherAlert(
+ id="marine-alert",
+ title="Small Craft Advisory",
+ description="Hazardous conditions expected.",
+ source="NWS",
+ )
+
+ with (
+ patch(
+ "accessiweather.weather_client_enrichment.nws_client._client_get",
+ new=AsyncMock(side_effect=[zone_response, alerts_response]),
+ ),
+ patch(
+ "accessiweather.weather_client_enrichment.nws_client.get_nws_marine_forecast",
+ new=AsyncMock(return_value=marine_forecast),
+ ),
+ patch(
+ "accessiweather.weather_client_enrichment.nws_client.parse_nws_alerts",
+ return_value=WeatherAlerts(alerts=[marine_alert]),
+ ),
+ ):
+ await enrich_with_marine_data(client, weather_data, location)
+
+ assert weather_data.marine is not None
+ assert weather_data.marine.zone_id == "ANZ530"
+ assert weather_data.marine.zone_name == "Chesapeake Bay from Pooles Island to Sandy Point"
+ assert weather_data.marine.forecast_summary == "South winds 10 to 15 knots. Waves 1 to 2 feet."
+ assert weather_data.marine.issued_at is not None
+ assert len(weather_data.marine.periods) == 2
+ assert weather_data.marine.highlights == [
+ "South winds 10 to 15 knots",
+ "Waves 1 to 2 feet",
+ "Gusts up to 20 knots",
+ "Seas around 2 feet",
+ ]
+ assert weather_data.alerts is not None
+ assert sorted(alert.id for alert in weather_data.alerts.alerts) == [
+ "existing-alert",
+ "marine-alert",
+ ]
+ merged_marine_alert = next(
+ alert for alert in weather_data.alerts.alerts if alert.id == "marine-alert"
+ )
+ assert merged_marine_alert.source == "NWS Marine"
+
+
+@pytest.mark.asyncio
+async def test_enrich_with_marine_data_returns_cleanly_when_no_zone_found():
+ location = Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True)
+ weather_data = WeatherData(location=location)
+ client = MagicMock()
+ client._is_us_location.return_value = True
+ client._get_http_client.return_value = object()
+ client.user_agent = "AccessiWeather Test"
+ client.nws_base_url = "https://api.weather.gov"
+
+ with patch(
+ "accessiweather.weather_client_enrichment.nws_client._client_get",
+ new=AsyncMock(return_value=_MockResponse({"features": []})),
+ ):
+ await enrich_with_marine_data(client, weather_data, location)
+
+ assert weather_data.marine is None
+ assert weather_data.alerts is None
diff --git a/tests/test_models.py b/tests/test_models.py
index 55ca186e..46d3dcfe 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -17,6 +17,8 @@
HourlyForecast,
HourlyForecastPeriod,
Location,
+ MarineForecast,
+ MarineForecastPeriod,
MinutelyPrecipitationForecast,
MinutelyPrecipitationPoint,
WeatherAlert,
@@ -359,6 +361,20 @@ def test_has_any_data(self):
)
assert with_minutely.has_any_data() is True
+ with_marine = WeatherData(
+ location=loc,
+ marine=MarineForecast(
+ zone_id="ANZ530",
+ periods=[
+ MarineForecastPeriod(
+ name="Tonight",
+ summary="South winds 10 to 15 knots with waves 1 to 2 feet.",
+ )
+ ],
+ ),
+ )
+ assert with_marine.has_any_data() is True
+
class TestAppSettings:
"""Tests for AppSettings model."""