openwind-ci commited on
Commit
ac89333
·
1 Parent(s): 5e911aa

sync: github b484b01

Browse files
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 — unchanged.
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 AUTO_FALLBACK_CHAIN:
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 != AUTO_FALLBACK_CHAIN[-1]:
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 AUTO_FALLBACK_CHAIN:
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)