Spaces:
Sleeping
Sleeping
| """Marine forecast types and adapter Protocol. | |
| Direction conventions (mixed by physical phenomenon — mirrors meteorological and | |
| oceanographic standards, do not normalise): | |
| - Wind direction (``WindPoint.direction_deg``): "from" — meteo standard (TWD). | |
| 0° = wind blowing from the north. | |
| - Wave direction (``SeaPoint.wave_direction_deg``): "from" — same as wind. | |
| - Ocean current direction (``SeaPoint.current_direction_to_deg``): "to" — | |
| oceanographic / nautical standard. 0° = current setting toward the north. | |
| Any code comparing wind vs current bearings (e.g. wind-against-current scoring) | |
| must explicitly normalise via ``(wind_from + 180) % 360`` to compare like with | |
| like. Mixing them silently is a bug. | |
| Speeds are in knots throughout the domain. Adapters convert at ingestion. | |
| Relevance thresholds (``CURRENT_*``, ``TIDE_*``) match the user-visible filter: | |
| currents and tide range only surface in the UI / MCP output when they exceed | |
| these values per leg. Tuned for the French coast (Med < threshold typically; | |
| Atlantic above on most legs). | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| from typing import Protocol | |
| CURRENT_RELEVANCE_THRESHOLD_KN = 0.3 | |
| TIDE_RANGE_RELEVANCE_THRESHOLD_M = 0.5 | |
| WIND_AGAINST_CURRENT_WARNING_THRESHOLD_KN = 1.5 | |
| WIND_AGAINST_CURRENT_OPPOSITION_DEG = 120.0 | |
| # Chop detection: short-period steep wind sea ("clapot"). Index = Hs / Tp^2 | |
| # (proxy for wave steepness). > 0.05 flags genuinely uncomfortable chop — | |
| # Hs 1.2 m at Tp 5 s, Hs 0.8 m at Tp 4 s. CHOP_HS_FLOOR_M guards against | |
| # absurd flags on ripples (Hs 0.3 m at Tp 2 s mathematically scores 0.075). | |
| CHOP_INDEX_THRESHOLD = 0.05 | |
| CHOP_HS_FLOOR_M = 0.8 | |
| # |TWA| >= 120° = sea coming from behind the boat (running / broad reach). | |
| # Chop on this angle is uncomfortable but not equivalent to taking it on the | |
| # bow: the boat moves with the wave, slamming is rare, and surfing is often | |
| # a gain. We still emit a warning (broaching / accidental gybe risks remain) | |
| # but skip the complexity bump when *all* chop segments are on this angle. | |
| CHOP_FOLLOWING_TWA_DEG = 120.0 | |
| class ForecastHorizonError(RuntimeError): | |
| """Raised when a forecast model's horizon does not cover the requested time. | |
| Carries the failing model, the requested timestamp, and a human-actionable | |
| message suggesting longer-horizon fallbacks. Open-Meteo silently returns | |
| empty rows past horizon, so detection happens after the fetch. | |
| """ | |
| def __init__(self, model: str, requested_time: datetime) -> None: | |
| self.model = model | |
| self.requested_time = requested_time | |
| super().__init__( | |
| f"forecast horizon exceeded for model {model!r} at " | |
| f"{requested_time.isoformat()}; AROME ~48h, ICON-EU ~5d, " | |
| f"ECMWF ~10d, GFS ~16d — try a longer-range model " | |
| f"or pass model='auto' to fall back automatically" | |
| ) | |
| class WindPoint: | |
| time: datetime | |
| speed_kn: float | |
| direction_deg: float | |
| gust_kn: float | None | |
| class SeaPoint: | |
| time: datetime | |
| wave_height_m: float | None | |
| wave_period_s: float | None | |
| wave_direction_deg: float | None | |
| wind_wave_height_m: float | None | |
| swell_wave_height_m: float | None | |
| current_speed_kn: float | None = None | |
| current_direction_to_deg: float | None = None | |
| tide_height_m: float | None = None | |
| # Provenance label for currents and tide_height: e.g. "openmeteo_smoc" | |
| # for the global Mercator product, "marc_finis_250m" / "marc_atlne_2km" | |
| # for the PREVIMER atlases. ``None`` when no current/tide data populated. | |
| current_source: str | None = None | |
| class WindSeries: | |
| model: str | |
| points: tuple[WindPoint, ...] | |
| class SeaSeries: | |
| points: tuple[SeaPoint, ...] | |
| class ForecastBundle: | |
| lat: float | |
| lon: float | |
| start: datetime | |
| end: datetime | |
| wind_by_model: dict[str, WindSeries] = field(default_factory=dict) | |
| sea: SeaSeries = field(default_factory=lambda: SeaSeries(points=())) | |
| requested_at: datetime | None = None | |
| class MarineDataAdapter(Protocol): | |
| async def fetch( | |
| self, | |
| lat: float, | |
| lon: float, | |
| start: datetime, | |
| end: datetime, | |
| models: list[str] | None = None, | |
| ) -> ForecastBundle: ... | |