Spaces:
Sleeping
Sleeping
| """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}" | |
| 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 | |
| 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, | |
| ) | |