Spaces:
Running
Running
openwind-ci commited on
Commit ·
ac89333
1
Parent(s): 5e911aa
sync: github b484b01
Browse files- app.py +107 -3
- vendor/data-adapters/src/openwind_data/routing/passage.py +25 -5
app.py
CHANGED
|
@@ -31,7 +31,7 @@ from mcp.server.transport_security import TransportSecuritySettings
|
|
| 31 |
from openwind_data.adapters.base import ForecastHorizonError
|
| 32 |
from openwind_data.currents.marc_atlas import MarcAtlasRegistry
|
| 33 |
from openwind_data.currents.shom_c2d_registry import ShomC2dRegistry
|
| 34 |
-
from openwind_data.routing.archetypes import list_archetypes_metadata
|
| 35 |
from openwind_data.routing.complexity import score_complexity
|
| 36 |
from openwind_data.routing.geometry import Point
|
| 37 |
from openwind_data.routing.passage import (
|
|
@@ -292,6 +292,94 @@ def _to_json(obj: Any) -> Any:
|
|
| 292 |
return obj
|
| 293 |
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
async def _index(_request) -> HTMLResponse:
|
| 296 |
return HTMLResponse(LANDING_HTML)
|
| 297 |
|
|
@@ -350,6 +438,12 @@ async def _api_passage(request: Request) -> JSONResponse:
|
|
| 350 |
except (TypeError, ValueError) as exc:
|
| 351 |
return JSONResponse({"error": f"invalid efficiency: {exc}"}, status_code=422)
|
| 352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
# Sweep mode — triggered when ``latest_departure`` is provided.
|
| 354 |
latest_raw = body.get("latest_departure")
|
| 355 |
if latest_raw is not None:
|
|
@@ -374,6 +468,7 @@ async def _api_passage(request: Request) -> JSONResponse:
|
|
| 374 |
reports = await estimate_passage_windows(
|
| 375 |
waypoints, departure, latest_departure, body["archetype"],
|
| 376 |
sweep_interval_hours=sweep_interval, efficiency=efficiency, model="auto",
|
|
|
|
| 377 |
)
|
| 378 |
except KeyError as exc:
|
| 379 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
|
@@ -454,10 +549,11 @@ async def _api_passage(request: Request) -> JSONResponse:
|
|
| 454 |
"forecast_updated_at": datetime.now(UTC).isoformat(),
|
| 455 |
})
|
| 456 |
|
| 457 |
-
# Single mode
|
| 458 |
try:
|
| 459 |
passage = await estimate_passage(
|
| 460 |
-
waypoints, departure, body["archetype"], efficiency=efficiency, model="auto"
|
|
|
|
| 461 |
)
|
| 462 |
except KeyError as exc:
|
| 463 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
|
@@ -517,6 +613,12 @@ async def _api_passage_by_eta(request: Request) -> JSONResponse:
|
|
| 517 |
except (TypeError, ValueError) as exc:
|
| 518 |
return JSONResponse({"error": f"invalid efficiency: {exc}"}, status_code=422)
|
| 519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
try:
|
| 521 |
plan = await estimate_passage_for_arrival(
|
| 522 |
waypoints,
|
|
@@ -524,6 +626,8 @@ async def _api_passage_by_eta(request: Request) -> JSONResponse:
|
|
| 524 |
body["archetype"],
|
| 525 |
efficiency=efficiency,
|
| 526 |
model="auto",
|
|
|
|
|
|
|
| 527 |
)
|
| 528 |
except KeyError as exc:
|
| 529 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
|
|
|
| 31 |
from openwind_data.adapters.base import ForecastHorizonError
|
| 32 |
from openwind_data.currents.marc_atlas import MarcAtlasRegistry
|
| 33 |
from openwind_data.currents.shom_c2d_registry import ShomC2dRegistry
|
| 34 |
+
from openwind_data.routing.archetypes import BoatPolar, list_archetypes_metadata
|
| 35 |
from openwind_data.routing.complexity import score_complexity
|
| 36 |
from openwind_data.routing.geometry import Point
|
| 37 |
from openwind_data.routing.passage import (
|
|
|
|
| 292 |
return obj
|
| 293 |
|
| 294 |
|
| 295 |
+
# Maps the web client's user-facing model names (see packages/web/src/config/
|
| 296 |
+
# modelConfig.ts) to the Open-Meteo unified-API slugs that the data-adapter
|
| 297 |
+
# already exercises in AUTO_FALLBACK_CHAIN. V1 scope: only the four chain
|
| 298 |
+
# members translate. Other web models (ARPEGE_*, ICON_GLOBAL/_D2, UKMO_*, GEM,
|
| 299 |
+
# DMI, METNO, ECMWF_AIFS) stay in the web table for forecast display but are
|
| 300 |
+
# silently dropped here because their slugs haven't been validated end-to-end
|
| 301 |
+
# against passage timing. Always append gfs_seamless as ultimate fallback so
|
| 302 |
+
# an exotic top-of-chain pick never leaves the chain empty at far horizons.
|
| 303 |
+
_MODEL_NAME_MAP: dict[str, str] = {
|
| 304 |
+
"AROME": "meteofrance_arome_france",
|
| 305 |
+
"ICON": "icon_eu",
|
| 306 |
+
"ECMWF": "ecmwf_ifs025",
|
| 307 |
+
"GFS": "gfs_seamless",
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _translate_models(raw: Any) -> tuple[str, ...] | None:
|
| 312 |
+
"""Translate web model names to Open-Meteo slugs. Returns None when the
|
| 313 |
+
caller didn't send a `models` field or the list is empty after filtering.
|
| 314 |
+
Always appends gfs_seamless as last-resort fallback unless already present.
|
| 315 |
+
"""
|
| 316 |
+
if not isinstance(raw, list):
|
| 317 |
+
return None
|
| 318 |
+
translated: list[str] = []
|
| 319 |
+
for name in raw:
|
| 320 |
+
if not isinstance(name, str):
|
| 321 |
+
continue
|
| 322 |
+
slug = _MODEL_NAME_MAP.get(name)
|
| 323 |
+
if slug and slug not in translated:
|
| 324 |
+
translated.append(slug)
|
| 325 |
+
if not translated:
|
| 326 |
+
return None
|
| 327 |
+
if "gfs_seamless" not in translated:
|
| 328 |
+
translated.append("gfs_seamless")
|
| 329 |
+
return tuple(translated)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _parse_polar(raw: Any) -> BoatPolar | None:
|
| 333 |
+
"""Build a BoatPolar from the web client's `polar` payload. Returns None
|
| 334 |
+
when no payload is provided. Raises ValueError on shape mismatch / invalid
|
| 335 |
+
values so the caller can surface a 422 with the original message.
|
| 336 |
+
"""
|
| 337 |
+
if raw is None:
|
| 338 |
+
return None
|
| 339 |
+
if not isinstance(raw, dict):
|
| 340 |
+
raise ValueError("polar must be an object")
|
| 341 |
+
try:
|
| 342 |
+
tws = [float(v) for v in raw["tws_kn"]]
|
| 343 |
+
twa = [float(v) for v in raw["twa_deg"]]
|
| 344 |
+
matrix = [[float(v) for v in row] for row in raw["boat_speed_kn"]]
|
| 345 |
+
except (KeyError, TypeError, ValueError) as exc:
|
| 346 |
+
raise ValueError(f"polar fields missing or non-numeric: {exc}") from exc
|
| 347 |
+
if len(tws) < 2 or len(twa) < 2:
|
| 348 |
+
raise ValueError("polar must have >= 2 TWS and >= 2 TWA entries")
|
| 349 |
+
from itertools import pairwise
|
| 350 |
+
if any(a >= b for a, b in pairwise(tws)):
|
| 351 |
+
raise ValueError("polar tws_kn must be strictly ascending")
|
| 352 |
+
if any(a >= b for a, b in pairwise(twa)):
|
| 353 |
+
raise ValueError("polar twa_deg must be strictly ascending")
|
| 354 |
+
if twa[0] < 0 or twa[-1] > 180:
|
| 355 |
+
raise ValueError("polar twa_deg must lie in [0, 180]")
|
| 356 |
+
if len(matrix) != len(tws):
|
| 357 |
+
raise ValueError(
|
| 358 |
+
f"polar boat_speed_kn has {len(matrix)} rows, expected {len(tws)} (one per TWS)"
|
| 359 |
+
)
|
| 360 |
+
for i, row in enumerate(matrix):
|
| 361 |
+
if len(row) != len(twa):
|
| 362 |
+
raise ValueError(
|
| 363 |
+
f"polar boat_speed_kn row {i} has {len(row)} cols, expected {len(twa)}"
|
| 364 |
+
)
|
| 365 |
+
for j, v in enumerate(row):
|
| 366 |
+
if v < 0 or v > 30:
|
| 367 |
+
raise ValueError(
|
| 368 |
+
f"polar boat_speed_kn[{i}][{j}]={v} out of range [0, 30]"
|
| 369 |
+
)
|
| 370 |
+
return BoatPolar(
|
| 371 |
+
name=str(raw.get("name", "custom")),
|
| 372 |
+
length_ft=int(raw.get("length_ft", 0) or 0),
|
| 373 |
+
type=str(raw.get("type", "monohull")),
|
| 374 |
+
category=str(raw.get("category", "custom")),
|
| 375 |
+
examples=tuple(str(e) for e in raw.get("examples", ())),
|
| 376 |
+
performance_class=str(raw.get("performance_class", "custom")),
|
| 377 |
+
tws_kn=tuple(tws),
|
| 378 |
+
twa_deg=tuple(twa),
|
| 379 |
+
boat_speed_kn=tuple(tuple(row) for row in matrix),
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
async def _index(_request) -> HTMLResponse:
|
| 384 |
return HTMLResponse(LANDING_HTML)
|
| 385 |
|
|
|
|
| 438 |
except (TypeError, ValueError) as exc:
|
| 439 |
return JSONResponse({"error": f"invalid efficiency: {exc}"}, status_code=422)
|
| 440 |
|
| 441 |
+
try:
|
| 442 |
+
polar_override = _parse_polar(body.get("polar"))
|
| 443 |
+
except ValueError as exc:
|
| 444 |
+
return JSONResponse({"error": f"invalid polar: {exc}"}, status_code=422)
|
| 445 |
+
model_chain = _translate_models(body.get("models"))
|
| 446 |
+
|
| 447 |
# Sweep mode — triggered when ``latest_departure`` is provided.
|
| 448 |
latest_raw = body.get("latest_departure")
|
| 449 |
if latest_raw is not None:
|
|
|
|
| 468 |
reports = await estimate_passage_windows(
|
| 469 |
waypoints, departure, latest_departure, body["archetype"],
|
| 470 |
sweep_interval_hours=sweep_interval, efficiency=efficiency, model="auto",
|
| 471 |
+
polar_override=polar_override, model_chain=model_chain,
|
| 472 |
)
|
| 473 |
except KeyError as exc:
|
| 474 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
|
|
|
| 549 |
"forecast_updated_at": datetime.now(UTC).isoformat(),
|
| 550 |
})
|
| 551 |
|
| 552 |
+
# Single mode.
|
| 553 |
try:
|
| 554 |
passage = await estimate_passage(
|
| 555 |
+
waypoints, departure, body["archetype"], efficiency=efficiency, model="auto",
|
| 556 |
+
polar_override=polar_override, model_chain=model_chain,
|
| 557 |
)
|
| 558 |
except KeyError as exc:
|
| 559 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
|
|
|
| 613 |
except (TypeError, ValueError) as exc:
|
| 614 |
return JSONResponse({"error": f"invalid efficiency: {exc}"}, status_code=422)
|
| 615 |
|
| 616 |
+
try:
|
| 617 |
+
polar_override = _parse_polar(body.get("polar"))
|
| 618 |
+
except ValueError as exc:
|
| 619 |
+
return JSONResponse({"error": f"invalid polar: {exc}"}, status_code=422)
|
| 620 |
+
model_chain = _translate_models(body.get("models"))
|
| 621 |
+
|
| 622 |
try:
|
| 623 |
plan = await estimate_passage_for_arrival(
|
| 624 |
waypoints,
|
|
|
|
| 626 |
body["archetype"],
|
| 627 |
efficiency=efficiency,
|
| 628 |
model="auto",
|
| 629 |
+
polar_override=polar_override,
|
| 630 |
+
model_chain=model_chain,
|
| 631 |
)
|
| 632 |
except KeyError as exc:
|
| 633 |
return JSONResponse({"error": f"unknown archetype: {exc}"}, status_code=422)
|
vendor/data-adapters/src/openwind_data/routing/passage.py
CHANGED
|
@@ -280,6 +280,8 @@ async def estimate_passage(
|
|
| 280 |
model: str = DEFAULT_MODEL,
|
| 281 |
heuristic_speed_kn: float = HEURISTIC_SPEED_KN,
|
| 282 |
use_wave_correction: bool = False,
|
|
|
|
|
|
|
| 283 |
) -> PassageReport:
|
| 284 |
"""Estimate a passage's per-segment timing, speed, and warnings.
|
| 285 |
|
|
@@ -314,8 +316,9 @@ async def estimate_passage(
|
|
| 314 |
raise ValueError("efficiency must be in (0, 1]")
|
| 315 |
|
| 316 |
if model == AUTO_MODEL:
|
|
|
|
| 317 |
last_err: ForecastHorizonError | None = None
|
| 318 |
-
for candidate in
|
| 319 |
try:
|
| 320 |
return await _estimate_with_model(
|
| 321 |
waypoints,
|
|
@@ -327,6 +330,7 @@ async def estimate_passage(
|
|
| 327 |
model=candidate,
|
| 328 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 329 |
use_wave_correction=use_wave_correction,
|
|
|
|
| 330 |
)
|
| 331 |
except ForecastHorizonError as exc:
|
| 332 |
last_err = exc
|
|
@@ -343,6 +347,7 @@ async def estimate_passage(
|
|
| 343 |
model=model,
|
| 344 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 345 |
use_wave_correction=use_wave_correction,
|
|
|
|
| 346 |
)
|
| 347 |
|
| 348 |
|
|
@@ -357,8 +362,9 @@ async def _estimate_with_model(
|
|
| 357 |
model: str,
|
| 358 |
heuristic_speed_kn: float,
|
| 359 |
use_wave_correction: bool,
|
|
|
|
| 360 |
) -> PassageReport:
|
| 361 |
-
polar = get_polar(boat_archetype)
|
| 362 |
effective_length_nm, capped_route_nm = _resolve_segment_length(waypoints, segment_length_nm)
|
| 363 |
segments = segment_route(waypoints, effective_length_nm)
|
| 364 |
departure_utc = departure_time.astimezone(UTC)
|
|
@@ -494,6 +500,7 @@ async def _estimate_backward_with_model(
|
|
| 494 |
model: str,
|
| 495 |
heuristic_speed_kn: float,
|
| 496 |
use_wave_correction: bool,
|
|
|
|
| 497 |
) -> PassageReport:
|
| 498 |
"""Mirror of `_estimate_with_model` anchored at arrival, solving backward.
|
| 499 |
|
|
@@ -505,7 +512,7 @@ async def _estimate_backward_with_model(
|
|
| 505 |
is needed. Mid-time guesses use `heuristic_speed_kn` like the forward path,
|
| 506 |
same temporal-correlation argument applies.
|
| 507 |
"""
|
| 508 |
-
polar = get_polar(boat_archetype)
|
| 509 |
effective_length_nm, capped_route_nm = _resolve_segment_length(waypoints, segment_length_nm)
|
| 510 |
segments = segment_route(waypoints, effective_length_nm)
|
| 511 |
target_utc = target_arrival.astimezone(UTC)
|
|
@@ -644,6 +651,8 @@ async def estimate_passage_windows(
|
|
| 644 |
adapter: MarineDataAdapter | None = None,
|
| 645 |
model: str = AUTO_MODEL,
|
| 646 |
use_wave_correction: bool = False,
|
|
|
|
|
|
|
| 647 |
) -> list[PassageReport]:
|
| 648 |
"""Simulate multiple departure windows for a fixed route.
|
| 649 |
|
|
@@ -708,6 +717,8 @@ async def estimate_passage_windows(
|
|
| 708 |
adapter=fetch_adapter,
|
| 709 |
model=model,
|
| 710 |
use_wave_correction=use_wave_correction,
|
|
|
|
|
|
|
| 711 |
)
|
| 712 |
resolved_model = first.model
|
| 713 |
reports: list[PassageReport] = [first]
|
|
@@ -733,6 +744,7 @@ async def estimate_passage_windows(
|
|
| 733 |
# the user pinned a specific model (model != "auto"), we respect that
|
| 734 |
# and skip out-of-horizon windows — explicit choice wins.
|
| 735 |
# ValueError / KeyError still bubble (caller-side bugs).
|
|
|
|
| 736 |
current = earliest_utc + timedelta(hours=sweep_interval_hours)
|
| 737 |
while current <= latest_utc:
|
| 738 |
try:
|
|
@@ -745,10 +757,11 @@ async def estimate_passage_windows(
|
|
| 745 |
adapter=fetch_adapter,
|
| 746 |
model=resolved_model,
|
| 747 |
use_wave_correction=use_wave_correction,
|
|
|
|
| 748 |
)
|
| 749 |
reports.append(report)
|
| 750 |
except ForecastHorizonError:
|
| 751 |
-
if model == AUTO_MODEL and resolved_model !=
|
| 752 |
try:
|
| 753 |
report = await estimate_passage(
|
| 754 |
waypoints,
|
|
@@ -759,6 +772,8 @@ async def estimate_passage_windows(
|
|
| 759 |
adapter=fetch_adapter,
|
| 760 |
model=AUTO_MODEL,
|
| 761 |
use_wave_correction=use_wave_correction,
|
|
|
|
|
|
|
| 762 |
)
|
| 763 |
reports.append(report)
|
| 764 |
except ForecastHorizonError:
|
|
@@ -782,6 +797,8 @@ async def estimate_passage_for_arrival(
|
|
| 782 |
model: str = AUTO_MODEL,
|
| 783 |
heuristic_speed_kn: float = HEURISTIC_SPEED_KN,
|
| 784 |
use_wave_correction: bool = False,
|
|
|
|
|
|
|
| 785 |
) -> EtaPassagePlan:
|
| 786 |
"""Inverse of `estimate_passage`: solve for a departure given a target arrival.
|
| 787 |
|
|
@@ -816,8 +833,9 @@ async def estimate_passage_for_arrival(
|
|
| 816 |
target_utc = target_arrival.astimezone(UTC)
|
| 817 |
|
| 818 |
if model == AUTO_MODEL:
|
|
|
|
| 819 |
last_err: ForecastHorizonError | None = None
|
| 820 |
-
for candidate in
|
| 821 |
try:
|
| 822 |
report = await _estimate_backward_with_model(
|
| 823 |
waypoints,
|
|
@@ -829,6 +847,7 @@ async def estimate_passage_for_arrival(
|
|
| 829 |
model=candidate,
|
| 830 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 831 |
use_wave_correction=use_wave_correction,
|
|
|
|
| 832 |
)
|
| 833 |
return EtaPassagePlan(report=report, target_arrival=target_utc)
|
| 834 |
except ForecastHorizonError as exc:
|
|
@@ -847,5 +866,6 @@ async def estimate_passage_for_arrival(
|
|
| 847 |
model=model,
|
| 848 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 849 |
use_wave_correction=use_wave_correction,
|
|
|
|
| 850 |
)
|
| 851 |
return EtaPassagePlan(report=report, target_arrival=target_utc)
|
|
|
|
| 280 |
model: str = DEFAULT_MODEL,
|
| 281 |
heuristic_speed_kn: float = HEURISTIC_SPEED_KN,
|
| 282 |
use_wave_correction: bool = False,
|
| 283 |
+
polar_override: BoatPolar | None = None,
|
| 284 |
+
model_chain: tuple[str, ...] | None = None,
|
| 285 |
) -> PassageReport:
|
| 286 |
"""Estimate a passage's per-segment timing, speed, and warnings.
|
| 287 |
|
|
|
|
| 316 |
raise ValueError("efficiency must be in (0, 1]")
|
| 317 |
|
| 318 |
if model == AUTO_MODEL:
|
| 319 |
+
chain = model_chain if model_chain else AUTO_FALLBACK_CHAIN
|
| 320 |
last_err: ForecastHorizonError | None = None
|
| 321 |
+
for candidate in chain:
|
| 322 |
try:
|
| 323 |
return await _estimate_with_model(
|
| 324 |
waypoints,
|
|
|
|
| 330 |
model=candidate,
|
| 331 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 332 |
use_wave_correction=use_wave_correction,
|
| 333 |
+
polar_override=polar_override,
|
| 334 |
)
|
| 335 |
except ForecastHorizonError as exc:
|
| 336 |
last_err = exc
|
|
|
|
| 347 |
model=model,
|
| 348 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 349 |
use_wave_correction=use_wave_correction,
|
| 350 |
+
polar_override=polar_override,
|
| 351 |
)
|
| 352 |
|
| 353 |
|
|
|
|
| 362 |
model: str,
|
| 363 |
heuristic_speed_kn: float,
|
| 364 |
use_wave_correction: bool,
|
| 365 |
+
polar_override: BoatPolar | None = None,
|
| 366 |
) -> PassageReport:
|
| 367 |
+
polar = polar_override if polar_override is not None else get_polar(boat_archetype)
|
| 368 |
effective_length_nm, capped_route_nm = _resolve_segment_length(waypoints, segment_length_nm)
|
| 369 |
segments = segment_route(waypoints, effective_length_nm)
|
| 370 |
departure_utc = departure_time.astimezone(UTC)
|
|
|
|
| 500 |
model: str,
|
| 501 |
heuristic_speed_kn: float,
|
| 502 |
use_wave_correction: bool,
|
| 503 |
+
polar_override: BoatPolar | None = None,
|
| 504 |
) -> PassageReport:
|
| 505 |
"""Mirror of `_estimate_with_model` anchored at arrival, solving backward.
|
| 506 |
|
|
|
|
| 512 |
is needed. Mid-time guesses use `heuristic_speed_kn` like the forward path,
|
| 513 |
same temporal-correlation argument applies.
|
| 514 |
"""
|
| 515 |
+
polar = polar_override if polar_override is not None else get_polar(boat_archetype)
|
| 516 |
effective_length_nm, capped_route_nm = _resolve_segment_length(waypoints, segment_length_nm)
|
| 517 |
segments = segment_route(waypoints, effective_length_nm)
|
| 518 |
target_utc = target_arrival.astimezone(UTC)
|
|
|
|
| 651 |
adapter: MarineDataAdapter | None = None,
|
| 652 |
model: str = AUTO_MODEL,
|
| 653 |
use_wave_correction: bool = False,
|
| 654 |
+
polar_override: BoatPolar | None = None,
|
| 655 |
+
model_chain: tuple[str, ...] | None = None,
|
| 656 |
) -> list[PassageReport]:
|
| 657 |
"""Simulate multiple departure windows for a fixed route.
|
| 658 |
|
|
|
|
| 717 |
adapter=fetch_adapter,
|
| 718 |
model=model,
|
| 719 |
use_wave_correction=use_wave_correction,
|
| 720 |
+
polar_override=polar_override,
|
| 721 |
+
model_chain=model_chain,
|
| 722 |
)
|
| 723 |
resolved_model = first.model
|
| 724 |
reports: list[PassageReport] = [first]
|
|
|
|
| 744 |
# the user pinned a specific model (model != "auto"), we respect that
|
| 745 |
# and skip out-of-horizon windows — explicit choice wins.
|
| 746 |
# ValueError / KeyError still bubble (caller-side bugs).
|
| 747 |
+
effective_chain = model_chain if model_chain else AUTO_FALLBACK_CHAIN
|
| 748 |
current = earliest_utc + timedelta(hours=sweep_interval_hours)
|
| 749 |
while current <= latest_utc:
|
| 750 |
try:
|
|
|
|
| 757 |
adapter=fetch_adapter,
|
| 758 |
model=resolved_model,
|
| 759 |
use_wave_correction=use_wave_correction,
|
| 760 |
+
polar_override=polar_override,
|
| 761 |
)
|
| 762 |
reports.append(report)
|
| 763 |
except ForecastHorizonError:
|
| 764 |
+
if model == AUTO_MODEL and resolved_model != effective_chain[-1]:
|
| 765 |
try:
|
| 766 |
report = await estimate_passage(
|
| 767 |
waypoints,
|
|
|
|
| 772 |
adapter=fetch_adapter,
|
| 773 |
model=AUTO_MODEL,
|
| 774 |
use_wave_correction=use_wave_correction,
|
| 775 |
+
polar_override=polar_override,
|
| 776 |
+
model_chain=model_chain,
|
| 777 |
)
|
| 778 |
reports.append(report)
|
| 779 |
except ForecastHorizonError:
|
|
|
|
| 797 |
model: str = AUTO_MODEL,
|
| 798 |
heuristic_speed_kn: float = HEURISTIC_SPEED_KN,
|
| 799 |
use_wave_correction: bool = False,
|
| 800 |
+
polar_override: BoatPolar | None = None,
|
| 801 |
+
model_chain: tuple[str, ...] | None = None,
|
| 802 |
) -> EtaPassagePlan:
|
| 803 |
"""Inverse of `estimate_passage`: solve for a departure given a target arrival.
|
| 804 |
|
|
|
|
| 833 |
target_utc = target_arrival.astimezone(UTC)
|
| 834 |
|
| 835 |
if model == AUTO_MODEL:
|
| 836 |
+
chain = model_chain if model_chain else AUTO_FALLBACK_CHAIN
|
| 837 |
last_err: ForecastHorizonError | None = None
|
| 838 |
+
for candidate in chain:
|
| 839 |
try:
|
| 840 |
report = await _estimate_backward_with_model(
|
| 841 |
waypoints,
|
|
|
|
| 847 |
model=candidate,
|
| 848 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 849 |
use_wave_correction=use_wave_correction,
|
| 850 |
+
polar_override=polar_override,
|
| 851 |
)
|
| 852 |
return EtaPassagePlan(report=report, target_arrival=target_utc)
|
| 853 |
except ForecastHorizonError as exc:
|
|
|
|
| 866 |
model=model,
|
| 867 |
heuristic_speed_kn=heuristic_speed_kn,
|
| 868 |
use_wave_correction=use_wave_correction,
|
| 869 |
+
polar_override=polar_override,
|
| 870 |
)
|
| 871 |
return EtaPassagePlan(report=report, target_arrival=target_utc)
|