openwind-ci
sync: github 84e0fc5
9ec7c7a
Raw
History Blame Contribute Delete
11.9 kB
"""Complexity scoring for a passage.
V1 design choices:
- **Two axes**: wind (TWS max over segments) and sea (Hs max). Each is binned
into a 1-5 level. The passage level is `max(wind_level, sea_level)` — the worst
axis dictates difficulty. No magic averaging.
- **Sea axis**: derived from per-segment Hs when present on the passage. Callers
may pass `max_hs_m` explicitly to override (e.g., for tests that build minimal
passages or to inject a forecast bulletin's worst case).
- **Rationale string**: human-readable f-string for the LLM/UI to surface.
- **Thresholds**: cruising-oriented bands. Documented in `docs/complexity.md`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from openwind_data.adapters.base import (
CHOP_FOLLOWING_TWA_DEG,
CHOP_HS_FLOOR_M,
CHOP_INDEX_THRESHOLD,
WIND_AGAINST_CURRENT_OPPOSITION_DEG,
WIND_AGAINST_CURRENT_WARNING_THRESHOLD_KN,
)
from openwind_data.routing.passage import PassageReport
# (upper_bound_exclusive, level, label). Last bucket has math.inf.
_WIND_BANDS: tuple[tuple[float, int, str], ...] = (
(10.0, 1, "calme"),
(15.0, 2, "modéré"),
(20.0, 3, "soutenu"),
(25.0, 4, "fort"),
(float("inf"), 5, "très fort"),
)
_SEA_BANDS: tuple[tuple[float, int, str], ...] = (
(0.5, 1, "plate"),
(1.0, 2, "belle"),
(2.0, 3, "agitée"),
(3.0, 4, "forte"),
(float("inf"), 5, "très forte"),
)
_LEVEL_LABELS: dict[int, str] = {
1: "facile",
2: "modéré",
3: "soutenu",
4: "exigeant",
5: "dangereux",
}
def _classify(value: float, bands: tuple[tuple[float, int, str], ...]) -> tuple[int, str]:
for upper, level, label in bands:
if value < upper:
return level, label
raise AssertionError("bands must end with +inf") # pragma: no cover
def _lower_bound(level: int, bands: tuple[tuple[float, int, str], ...]) -> float:
"""Return the inclusive lower bound of value that maps to `level`."""
prev: float = 0.0
for upper, lv, _ in bands:
if lv == level:
return prev
prev = upper
return 0.0 # pragma: no cover
def _compact_range(values: list[float], decimals: int) -> str:
"""`"30"` for {30}, `"25-30"` for the spread, rounded to `decimals` digits.
Reporting a single max obscured how the conditions actually played out:
"TWS max 30 kn" reads like one moment of stress, when the affected stretch
might have been steady at 25-30 kn. The range is more informative for both
the LLM (decision-making) and the human reader.
"""
rounded = sorted({round(v, decimals) for v in values})
if len(rounded) == 1:
return f"{rounded[0]:.{decimals}f}"
return f"{rounded[0]:.{decimals}f}-{rounded[-1]:.{decimals}f}"
@dataclass(frozen=True, slots=True)
class ComplexityWarning:
kind: Literal["wind", "sea", "current", "chop"]
level: int # 1..5 — same scale as the axis that triggered it
message: str
affected_segments: tuple[int, ...] # indices into PassageReport.segments
@dataclass(frozen=True, slots=True)
class ComplexityScore:
level: int # 1..5
label: str # facile / modéré / soutenu / exigeant / dangereux
wind_level: int
wind_label: str
sea_level: int | None
sea_label: str | None
tws_max_kn: float
hs_max_m: float | None
rationale: str
warnings: tuple[ComplexityWarning, ...] = ()
wind_against_current: bool = False # True when at least one segment triggered the bump
chop_present: bool = False # True when short-period steep wind sea is flagged
def score_complexity(
passage: PassageReport,
*,
max_hs_m: float | None = None,
) -> ComplexityScore:
"""Score a passage on a 1-5 scale from wind (and optionally sea).
Args:
passage: a `PassageReport` with at least one segment.
max_hs_m: optional max significant wave height over the route, in meters.
When `None`, derived from segment Hs values on the passage; falls
back to the wind-only score if no segment carries Hs.
"""
if not passage.segments:
raise ValueError("passage has no segments")
tws_max = max(s.tws_kn for s in passage.segments)
wind_level, wind_label = _classify(tws_max, _WIND_BANDS)
if max_hs_m is None:
seg_hs = [s.hs_m for s in passage.segments if s.hs_m is not None]
max_hs_m = max(seg_hs) if seg_hs else None
elif max_hs_m < 0:
raise ValueError("max_hs_m must be >= 0")
if max_hs_m is None:
sea_level: int | None = None
sea_label: str | None = None
level = wind_level
rationale = f"vent max {tws_max:.0f} kn ({wind_label})"
else:
sea_level, sea_label = _classify(max_hs_m, _SEA_BANDS)
level = max(wind_level, sea_level)
rationale = (
f"vent max {tws_max:.0f} kn ({wind_label}), mer max Hs={max_hs_m:.1f} m ({sea_label})"
)
warnings: list[ComplexityWarning] = []
if wind_level >= 3:
threshold = _lower_bound(wind_level, _WIND_BANDS)
affected = tuple(i for i, s in enumerate(passage.segments) if s.tws_kn >= threshold)
affected_nm = sum(passage.segments[i].distance_nm for i in affected)
tws_range = _compact_range([passage.segments[i].tws_kn for i in affected], 0)
warnings.append(
ComplexityWarning(
kind="wind",
level=wind_level,
message=f"Vent {wind_label} : TWS {tws_range} kn sur {affected_nm:.0f} nm",
affected_segments=affected,
)
)
if sea_level is not None and sea_level >= 3 and max_hs_m is not None:
threshold = _lower_bound(sea_level, _SEA_BANDS)
affected_sea = tuple(
i for i, s in enumerate(passage.segments) if s.hs_m is not None and s.hs_m >= threshold
)
# If max_hs_m came from a route-level override (no per-segment Hs on the
# passage), default the affected span to the whole route so the warning
# still reports a meaningful distance instead of "0 nm".
if not affected_sea:
affected_sea = tuple(range(len(passage.segments)))
affected_sea_nm = sum(passage.segments[i].distance_nm for i in affected_sea)
affected_hs = [
passage.segments[i].hs_m for i in affected_sea if passage.segments[i].hs_m is not None
]
hs_range = _compact_range(affected_hs, 1) if affected_hs else f"{max_hs_m:.1f}"
warnings.append(
ComplexityWarning(
kind="sea",
level=sea_level,
message=f"Mer {sea_label} : Hs {hs_range} m sur {affected_sea_nm:.0f} nm",
affected_segments=affected_sea,
)
)
# Wind-against-current detection: a segment triggers when current ≥ 1.5 kt
# AND wind_to (twd + 180) is opposed to current_to by ≥ 120°. Mediterranean
# legs almost never qualify; Atlantic tidal passes (Goulet de Brest, Raz de
# Sein) routinely do. Triggers a +1 bump on the overall level (cap 5) plus
# an explicit warning so the LLM/UI can flag the chop, mirroring nautical
# practice. The bump is shared with the chop detector below — both flag
# broken/uncomfortable sea and only contribute +1 in total.
wac_indices: list[int] = []
wac_currents: list[float] = []
for i, s in enumerate(passage.segments):
if s.current_speed_kn is None or s.current_direction_to_deg is None:
continue
if s.current_speed_kn < WIND_AGAINST_CURRENT_WARNING_THRESHOLD_KN:
continue
wind_to = (s.twd_deg + 180.0) % 360.0
delta = abs(((wind_to - s.current_direction_to_deg + 540.0) % 360.0) - 180.0)
if delta >= WIND_AGAINST_CURRENT_OPPOSITION_DEG:
wac_indices.append(i)
wac_currents.append(s.current_speed_kn)
wind_against_current = bool(wac_indices)
# Chop detection: short-period steep wind sea ("clapot"). Index = Hs/Tp²
# is a steepness proxy. We exclude segments already flagged by WAC since
# the WAC warning ("mer hachée probable") already covers chop on those
# legs — no need to fire two warnings for the same phenomenon.
chop_indices: list[int] = []
chop_hs: list[float] = []
chop_tp: list[float] = []
wac_index_set = set(wac_indices)
for i, s in enumerate(passage.segments):
if i in wac_index_set:
continue
if s.hs_m is None or s.wave_period_s is None:
continue
if s.hs_m < CHOP_HS_FLOOR_M or s.wave_period_s <= 0:
continue
if s.hs_m / (s.wave_period_s**2) > CHOP_INDEX_THRESHOLD:
chop_indices.append(i)
chop_hs.append(s.hs_m)
chop_tp.append(s.wave_period_s)
chop_present = bool(chop_indices)
# Following chop (sea from behind) is much less penalising — emit the
# warning so the sailor sees it but skip the bump when *every* chop
# segment is on a running angle. A single bow-on or beam segment in the
# set still bumps, because that's where slamming happens.
chop_following_only = chop_present and all(
abs(passage.segments[i].twa_deg) >= CHOP_FOLLOWING_TWA_DEG for i in chop_indices
)
chop_contributes_bump = chop_present and not chop_following_only
# Single +1 bump shared by WAC and chop — both describe broken/uncomfortable
# sea and don't compound. Without this, a passage with WAC on the first half
# and chop on the second half would jump +2 levels for what is essentially
# the same physical signal restated.
bumped_level = min(5, level + 1) if (wind_against_current or chop_contributes_bump) else level
if wind_against_current:
affected_wac = tuple(wac_indices)
affected_wac_nm = sum(passage.segments[i].distance_nm for i in affected_wac)
cur_range = _compact_range(wac_currents, 1)
warnings.append(
ComplexityWarning(
kind="current",
level=bumped_level,
message=(
f"Vent contre courant : courant {cur_range} kt opposé sur "
f"{affected_wac_nm:.0f} nm, mer hachée probable"
),
affected_segments=affected_wac,
)
)
rationale = f"{rationale}, vent contre courant"
if chop_present:
affected_chop = tuple(chop_indices)
affected_chop_nm = sum(passage.segments[i].distance_nm for i in affected_chop)
hs_range = _compact_range(chop_hs, 1)
tp_range = _compact_range(chop_tp, 0)
if chop_following_only:
chop_label = "Clapot suiveur"
chop_suffix: str | None = None
chop_warning_level = level # no bump credited to this warning
else:
chop_label = "Clapot court"
chop_suffix = "mer désagréable"
chop_warning_level = bumped_level
suffix_part = f", {chop_suffix}" if chop_suffix else ""
warnings.append(
ComplexityWarning(
kind="chop",
level=chop_warning_level,
message=(
f"{chop_label} : Hs {hs_range} m à Tp {tp_range} s sur "
f"{affected_chop_nm:.0f} nm{suffix_part}"
),
affected_segments=affected_chop,
)
)
rationale = f"{rationale}, {chop_label.lower()}"
level = bumped_level
return ComplexityScore(
level=level,
label=_LEVEL_LABELS[level],
wind_level=wind_level,
wind_label=wind_label,
sea_level=sea_level,
sea_label=sea_label,
tws_max_kn=tws_max,
hs_max_m=max_hs_m,
rationale=rationale,
warnings=tuple(warnings),
wind_against_current=wind_against_current,
chop_present=chop_present,
)