openwind-ci commited on
Commit
e904b2e
·
1 Parent(s): 522ef7c

sync: github 3e9a578

Browse files
Dockerfile CHANGED
@@ -15,19 +15,23 @@ COPY vendor/mcp-core /app/mcp-core
15
  RUN pip install /app/data-adapters /app/mcp-core "uvicorn[standard]>=0.30" \
16
  "huggingface_hub[hf_xet]>=0.24"
17
 
18
- # Pull the MARC PREVIMER atlases at build time. The dataset is private so we
19
- # need an HF token with read access. On HF Spaces, define the secret in
20
- # Settings -> Variables and secrets -> Secrets:
21
  # HF_TOKEN (User Access Token with read scope on Qdonnars/openwind-tidal-atlas)
22
- # Runtime reads it back via MARC_ATLAS_DIR (set below). The fetch script falls
23
- # back gracefully (creates an empty MARC_ATLAS_DIR, runtime uses Open-Meteo
24
- # SMOC only) when the secret is absent.
 
 
25
  ENV HF_DATASET_ID=Qdonnars/openwind-tidal-atlas
26
- ENV MARC_ATLAS_DIR=/app/data/marc-atlas
27
- COPY fetch_marc_dataset.py /tmp/fetch_marc_dataset.py
 
 
28
  RUN --mount=type=secret,id=HF_TOKEN,required=false \
29
  sh -c 'if [ -f /run/secrets/HF_TOKEN ]; then export HF_TOKEN="$(cat /run/secrets/HF_TOKEN)"; fi; \
30
- python /tmp/fetch_marc_dataset.py'
31
 
32
  COPY app.py /app/app.py
33
 
 
15
  RUN pip install /app/data-adapters /app/mcp-core "uvicorn[standard]>=0.30" \
16
  "huggingface_hub[hf_xet]>=0.24"
17
 
18
+ # Pull the tidal atlas dataset at build time (MARC PREVIMER + SHOM Atlas C2D).
19
+ # The dataset is private so we need an HF token with read access. On HF
20
+ # Spaces, define the secret in Settings -> Variables and secrets -> Secrets:
21
  # HF_TOKEN (User Access Token with read scope on Qdonnars/openwind-tidal-atlas)
22
+ # Runtime reads MARC subdirs via MARC_ATLAS_DIR and the SHOM Parquet+JSON via
23
+ # SHOM_C2D_DIR; both point at the same shared directory because the layout
24
+ # inside the dataset places SHOM artefacts at root next to the per-atlas MARC
25
+ # subdirs. The fetch script falls back gracefully (empty dir, runtime uses
26
+ # Open-Meteo SMOC only) when the secret is absent.
27
  ENV HF_DATASET_ID=Qdonnars/openwind-tidal-atlas
28
+ ENV ATLAS_DATA_DIR=/app/data/atlas
29
+ ENV MARC_ATLAS_DIR=/app/data/atlas
30
+ ENV SHOM_C2D_DIR=/app/data/atlas
31
+ COPY fetch_atlases.py /tmp/fetch_atlases.py
32
  RUN --mount=type=secret,id=HF_TOKEN,required=false \
33
  sh -c 'if [ -f /run/secrets/HF_TOKEN ]; then export HF_TOKEN="$(cat /run/secrets/HF_TOKEN)"; fi; \
34
+ python /tmp/fetch_atlases.py'
35
 
36
  COPY app.py /app/app.py
37
 
fetch_atlases.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pull tidal atlases from the HF Dataset at Docker build time.
2
+
3
+ Reads ``HF_TOKEN`` (mounted as a build secret) and ``HF_DATASET_ID`` from
4
+ the environment, snapshots the dataset under ``ATLAS_DATA_DIR`` (default
5
+ ``/app/data/atlas``), and reports what was fetched. Falls back gracefully
6
+ (empty dir, runtime uses Open-Meteo SMOC only) when the token is absent
7
+ so contributors can build the image without an HF account.
8
+
9
+ The downloaded layout is expected to contain both:
10
+
11
+ - MARC PREVIMER atlas tiles at ``<ATLAS_DATA_DIR>/<ATLAS>/`` (e.g. ``FINIS/``,
12
+ ``SUDBZH/``, ``MANGA/``), one per Ifremer atlas. Read at runtime by
13
+ ``MARC_ATLAS_DIR``.
14
+ - SHOM Atlas C2D artefacts at the dataset root: ``shom_c2d_points.parquet``
15
+ + ``shom_c2d_ref_ports.json``. Read at runtime by ``SHOM_C2D_DIR``.
16
+
17
+ Both env vars (``MARC_ATLAS_DIR`` and ``SHOM_C2D_DIR``) are set to the
18
+ same directory by the Dockerfile, so the data-adapters loaders find
19
+ their respective files side by side. Either source can be missing from
20
+ the dataset and the runtime degrades the cascade accordingly:
21
+ SHOM → MARC → SMOC.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+
31
+ def main() -> None:
32
+ target = os.environ.get("ATLAS_DATA_DIR", "/app/data/atlas")
33
+ dataset_id = os.environ.get("HF_DATASET_ID", "Qdonnars/openwind-tidal-atlas")
34
+ token = os.environ.get("HF_TOKEN")
35
+
36
+ if not token:
37
+ print("WARNING: HF_TOKEN not set, skipping atlas dataset download")
38
+ print("plan_passage will fall back to Open-Meteo SMOC only")
39
+ os.makedirs(target, exist_ok=True)
40
+ return
41
+
42
+ from huggingface_hub import snapshot_download
43
+
44
+ print(f"Fetching {dataset_id} -> {target}")
45
+ snapshot_download(dataset_id, repo_type="dataset", local_dir=target, token=token)
46
+
47
+ target_path = Path(target)
48
+ marc_atlases = sorted(
49
+ p.name
50
+ for p in target_path.iterdir()
51
+ if p.is_dir() and (p / "metadata.json").exists()
52
+ )
53
+ shom_present = (
54
+ (target_path / "shom_c2d_points.parquet").exists()
55
+ and (target_path / "shom_c2d_ref_ports.json").exists()
56
+ )
57
+ print(f"Atlas dataset cached at {target}")
58
+ print(f" MARC atlases: {marc_atlases or '<none>'}")
59
+ print(f" SHOM Atlas C2D: {'present' if shom_present else '<absent>'}")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ try:
64
+ main()
65
+ except Exception as exc:
66
+ print(f"ERROR fetching atlas dataset: {exc}", file=sys.stderr)
67
+ # Don't fail the build — runtime will fall back as best it can.
68
+ os.makedirs(os.environ.get("ATLAS_DATA_DIR", "/app/data/atlas"), exist_ok=True)
fetch_marc_dataset.py DELETED
@@ -1,40 +0,0 @@
1
- """Pull MARC PREVIMER atlases from the HF Dataset at Docker build time.
2
-
3
- Reads ``HF_TOKEN`` (mounted as a build secret), ``HF_DATASET_ID`` and
4
- ``MARC_ATLAS_DIR`` from the environment. Falls back gracefully (no MARC
5
- data, runtime uses Open-Meteo SMOC) if the token is absent — this lets
6
- contributors build the image without an HF account.
7
- """
8
- from __future__ import annotations
9
-
10
- import os
11
- import sys
12
-
13
-
14
- def main() -> None:
15
- target = os.environ.get("MARC_ATLAS_DIR", "/app/data/marc-atlas")
16
- dataset_id = os.environ.get("HF_DATASET_ID", "Qdonnars/openwind-tidal-atlas")
17
- token = os.environ.get("HF_TOKEN")
18
-
19
- if not token:
20
- print("WARNING: HF_TOKEN not set, skipping MARC dataset download")
21
- print("plan_passage will fall back to Open-Meteo SMOC only")
22
- os.makedirs(target, exist_ok=True)
23
- return
24
-
25
- from huggingface_hub import snapshot_download
26
-
27
- print(f"Fetching {dataset_id} -> {target}")
28
- snapshot_download(dataset_id, repo_type="dataset", local_dir=target, token=token)
29
- atlases = sorted(p for p in os.listdir(target) if not p.startswith("."))
30
- print(f"MARC dataset cached at {target}")
31
- print(f"Atlases: {atlases}")
32
-
33
-
34
- if __name__ == "__main__":
35
- try:
36
- main()
37
- except Exception as exc:
38
- print(f"ERROR fetching MARC dataset: {exc}", file=sys.stderr)
39
- # Don't fail the build — runtime will fall back to Open-Meteo SMOC.
40
- os.makedirs(os.environ.get("MARC_ATLAS_DIR", "/app/data/marc-atlas"), exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vendor/data-adapters/src/openwind_data/currents/narrow_pass.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-point confidence labelling for current values.
2
+
3
+ The qualitative tag (``"high"`` / ``"medium"`` / ``"low"`` / ``None``) sits
4
+ beside ``current_source`` on each ``SegmentReport`` so the LLM and UI can
5
+ qualify a current value without re-deriving the rules.
6
+
7
+ The labelling is **source-based for now**. We previously shipped a
8
+ hand-drawn list of named narrow-pass bboxes (Goulet de Brest, Raz de Sein,
9
+ Goulet du Morbihan, etc.) to downgrade confidence inside known choke
10
+ points. That approach was unprincipled — bboxes drawn by intuition rather
11
+ than by physics — and has been removed. The data-driven replacement will
12
+ land with the SHOM Atlas C2D ingestion: zones where C2D peak speeds
13
+ exceed a threshold (e.g. ≥ 3 kt at vives-eaux) are exactly the zones
14
+ where every freely-available product under-resolves the choke, so the
15
+ confidence downgrade can be derived from the data instead of hand-drawn.
16
+
17
+ Until that adapter is wired, ``confidence_for_point`` reflects only the
18
+ source product's intrinsic resolution.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Literal
24
+
25
+ ConfidenceLevel = Literal["high", "medium", "low"]
26
+
27
+
28
+ def confidence_for_point(lat: float, lon: float, source: str | None) -> ConfidenceLevel | None:
29
+ """Confidence tag for the current value at (lat, lon) with given source.
30
+
31
+ - ``None`` source → ``None`` (no current data, nothing to qualify).
32
+ - SHOM Atlas C2D (``"shom_c2d_*"``) → ``"high"``: French navigation
33
+ reference, hand-placed points on flow features, validated against
34
+ in-situ measurements.
35
+ - MARC PREVIMER (``"marc_*"``) → ``"high"``: Ifremer harmonic atlas,
36
+ regular 250 m to 2 km grid depending on zone.
37
+ - Open-Meteo SMOC (``"openmeteo_smoc"``) → ``"medium"``: 8 km global
38
+ Mercator product, fine for open water but blunt near the coast.
39
+ - Anything else → ``"medium"`` (unknown source, stay conservative).
40
+
41
+ The ``lat`` and ``lon`` arguments are reserved for the data-driven
42
+ successor (SHOM-peak-based downgrade in choke points) and are
43
+ currently unused.
44
+ """
45
+ del lat, lon # reserved for the data-driven successor
46
+ if source is None:
47
+ return None
48
+ if source.startswith("shom_c2d_") or source.startswith("marc_"):
49
+ return "high"
50
+ if source == "openmeteo_smoc":
51
+ return "medium"
52
+ return "medium"
vendor/data-adapters/src/openwind_data/currents/router.py CHANGED
@@ -1,14 +1,27 @@
1
- """Composite marine adapter — MARC where covered, Open-Meteo SMOC elsewhere.
2
 
3
- Wraps an upstream ``MarineDataAdapter`` (typically ``OpenMeteoAdapter``) and
4
- a ``MarcAtlasRegistry``. Returns a ``ForecastBundle`` whose ``sea`` series
5
- has currents and tide heights overridden by MARC predictions when the query
6
- point falls inside a MARC emprise. Wave fields are always passed through
7
- from Open-Meteo (MARC has no wave atlases).
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  Provenance is exposed on each ``SeaPoint`` via ``current_source``:
10
- ``"marc_<atlas_lower>_<res>m"`` (e.g. ``"marc_finis_250m"``) inside MARC,
11
- ``"openmeteo_smoc"`` outside.
12
  """
13
 
14
  from __future__ import annotations
@@ -23,6 +36,7 @@ from openwind_data.adapters.base import (
23
  SeaSeries,
24
  )
25
  from openwind_data.currents.marc_atlas import MarcAtlasRegistry
 
26
 
27
 
28
  def _marc_source_label(atlas_name: str, resolution_m: int) -> str:
@@ -31,14 +45,21 @@ def _marc_source_label(atlas_name: str, resolution_m: int) -> str:
31
 
32
  @dataclass
33
  class CompositeMarineAdapter:
34
- """``MarineDataAdapter`` that overrides Open-Meteo currents/tide with MARC.
 
35
 
36
  Methods on the upstream adapter (e.g. ``aclose``) are not delegated;
37
  callers manage the lifecycle of the upstream they pass in.
 
 
 
 
 
38
  """
39
 
40
  upstream: MarineDataAdapter
41
  marc: MarcAtlasRegistry
 
42
 
43
  async def fetch(
44
  self,
@@ -49,9 +70,13 @@ class CompositeMarineAdapter:
49
  models: list[str] | None = None,
50
  ) -> ForecastBundle:
51
  bundle = await self.upstream.fetch(lat, lon, start, end, models=models)
 
 
 
 
52
  atlas = self.marc.covers(lat, lon)
53
  if atlas is None:
54
- return bundle # outside MARC, keep Open-Meteo
55
 
56
  # Inside MARC: predict the full series in one shot (vectorised).
57
  times = [p.time for p in bundle.sea.points]
@@ -100,3 +125,47 @@ class CompositeMarineAdapter:
100
  sea=SeaSeries(points=tuple(new_points)),
101
  requested_at=bundle.requested_at,
102
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Composite marine adapter — SHOM > MARC > Open-Meteo cascade.
2
 
3
+ Wraps an upstream ``MarineDataAdapter`` (typically ``OpenMeteoAdapter``)
4
+ plus a ``MarcAtlasRegistry`` and a ``ShomC2dRegistry``. Returns a
5
+ ``ForecastBundle`` whose ``sea`` series has currents (and tide heights, for
6
+ MARC only SHOM C2D does not carry heights) overridden by the finest
7
+ available source at each waypoint:
8
+
9
+ 1. **SHOM Atlas C2D** (top priority): the French navigation reference.
10
+ Hand-curated scattered points on flow features in coastal cartouches.
11
+ Used wherever a SHOM point sits within ~5 km of the query.
12
+ 2. **MARC PREVIMER** (mid priority): regular harmonic grid (250 m to
13
+ 2 km). Fills the continuous coastal/shelf coverage that SHOM doesn't
14
+ sample.
15
+ 3. **Open-Meteo SMOC** (fallback): 8 km global Mercator. Used only when
16
+ neither SHOM nor MARC cover the waypoint.
17
+
18
+ Wave fields are always passed through from Open-Meteo (no SHOM/MARC wave
19
+ atlases). Tide heights come from MARC only when the waypoint falls
20
+ inside a MARC emprise — SHOM C2D doesn't ship height series.
21
 
22
  Provenance is exposed on each ``SeaPoint`` via ``current_source``:
23
+ ``"shom_c2d_<atlas_id>_<zone>"`` inside SHOM, ``"marc_<atlas>_<res>m"``
24
+ inside MARC-only zones, ``"openmeteo_smoc"`` outside both.
25
  """
26
 
27
  from __future__ import annotations
 
36
  SeaSeries,
37
  )
38
  from openwind_data.currents.marc_atlas import MarcAtlasRegistry
39
+ from openwind_data.currents.shom_c2d_registry import ShomC2dRegistry
40
 
41
 
42
  def _marc_source_label(atlas_name: str, resolution_m: int) -> str:
 
45
 
46
  @dataclass
47
  class CompositeMarineAdapter:
48
+ """``MarineDataAdapter`` that overrides Open-Meteo currents/tide via the
49
+ SHOM > MARC > SMOC cascade.
50
 
51
  Methods on the upstream adapter (e.g. ``aclose``) are not delegated;
52
  callers manage the lifecycle of the upstream they pass in.
53
+
54
+ ``shom`` is optional; when omitted (or empty), the cascade reduces to
55
+ MARC > SMOC and the adapter behaves identically to the previous
56
+ two-tier version. This lets callers skip SHOM in benches or in
57
+ deployments where the C2D artefacts aren't shipped.
58
  """
59
 
60
  upstream: MarineDataAdapter
61
  marc: MarcAtlasRegistry
62
+ shom: ShomC2dRegistry | None = None
63
 
64
  async def fetch(
65
  self,
 
70
  models: list[str] | None = None,
71
  ) -> ForecastBundle:
72
  bundle = await self.upstream.fetch(lat, lon, start, end, models=models)
73
+ # Try SHOM first (highest priority). When it covers, override the
74
+ # currents only — wave and tide fields stay on Open-Meteo / MARC.
75
+ if self.shom is not None and self.shom.covers(lat, lon):
76
+ return self._apply_shom(bundle, lat, lon)
77
  atlas = self.marc.covers(lat, lon)
78
  if atlas is None:
79
+ return bundle # outside SHOM and MARC, keep Open-Meteo
80
 
81
  # Inside MARC: predict the full series in one shot (vectorised).
82
  times = [p.time for p in bundle.sea.points]
 
125
  sea=SeaSeries(points=tuple(new_points)),
126
  requested_at=bundle.requested_at,
127
  )
128
+
129
+ def _apply_shom(self, bundle: ForecastBundle, lat: float, lon: float) -> ForecastBundle:
130
+ """Override the bundle's currents with SHOM Atlas C2D predictions.
131
+
132
+ Wave fields stay on Open-Meteo. Tide height also stays on
133
+ Open-Meteo (or falls through to MARC if a separate MARC override
134
+ also applies — currently mutually exclusive in the cascade since
135
+ SHOM takes priority). The source label embeds atlas id + zone
136
+ name, e.g. ``"shom_c2d_558_morbihan"``.
137
+ """
138
+ if self.shom is None: # narrows the Optional for type checkers
139
+ return bundle
140
+ times = [p.time for p in bundle.sea.points]
141
+ if not times:
142
+ return bundle
143
+ result = self.shom.predict_current_series(lat, lon, times)
144
+ if result is None:
145
+ return bundle
146
+ speeds_kn, dirs_to_deg, source_label = result
147
+ new_points: list[SeaPoint] = []
148
+ for i, p in enumerate(bundle.sea.points):
149
+ new_points.append(
150
+ SeaPoint(
151
+ time=p.time,
152
+ wave_height_m=p.wave_height_m,
153
+ wave_period_s=p.wave_period_s,
154
+ wave_direction_deg=p.wave_direction_deg,
155
+ wind_wave_height_m=p.wind_wave_height_m,
156
+ swell_wave_height_m=p.swell_wave_height_m,
157
+ current_speed_kn=float(speeds_kn[i]),
158
+ current_direction_to_deg=float(dirs_to_deg[i]),
159
+ tide_height_m=p.tide_height_m,
160
+ current_source=source_label,
161
+ )
162
+ )
163
+ return ForecastBundle(
164
+ lat=bundle.lat,
165
+ lon=bundle.lon,
166
+ start=bundle.start,
167
+ end=bundle.end,
168
+ wind_by_model=bundle.wind_by_model,
169
+ sea=SeaSeries(points=tuple(new_points)),
170
+ requested_at=bundle.requested_at,
171
+ )
vendor/data-adapters/src/openwind_data/currents/shom_c2d.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser for the SHOM "Courants de marée 2D" ASCII atlas (Édition 2005).
2
+
3
+ This module loads the public-domain dataset distributed by SHOM under
4
+ Licence Ouverte v2.0 (cf. ``data.gouv.fr/datasets/courants-de-maree-des-cotes-
5
+ de-france-manche-atlantique-produit-numerique-1``). The product covers the
6
+ French Manche and Atlantic coasts with hand-curated current vectors at
7
+ discrete points, organised hour-by-hour relative to the high or low tide
8
+ of a reference port.
9
+
10
+ It is **not** wired into the runtime cascade — MARC PREVIMER (250 m on
11
+ critical passes, 700 m on the shelf) is finer than every C2D zone except
12
+ a few micro-cartouches (Bloscon 170 m, Roscoff 160 m). C2D's value lives
13
+ elsewhere:
14
+
15
+ 1. As a **bench reference** for measuring MARC skill at named hotspots
16
+ (Goulet de Brest, Raz de Sein, Ouessant, Goulet du Morbihan,
17
+ Saint-Malo, Hague). C2D is built from a different model lineage
18
+ (TELEMAC-2D or SHOM finite-difference) with denser bathymetry and
19
+ has been validated against SHOM in-situ measurements, so divergence
20
+ between MARC and C2D at the same point is meaningful signal.
21
+ 2. As a **cross-check** for narrow-pass-zone definitions: where C2D and
22
+ MARC disagree by more than X%, the bbox is a candidate for the
23
+ ``narrow_pass`` registry.
24
+
25
+ Format reference: ``DOCUMENTATION/NoticeCourants.pdf`` §8.2 inside the
26
+ SHOM C2D distribution. Each zone file is ASCII Latin-1 encoded:
27
+
28
+ - Line 1: reference port name. Suffix ``.BM`` (or ``BM``) means the
29
+ hourly index is referenced to **low** tide; otherwise high tide.
30
+ - For each grid point, three lines:
31
+ 1. Position WGS84 ``sDDMM.mmm sDDDMM.mmm`` (lat, lon, signed degrees +
32
+ decimal minutes; lat positive = N, lon positive = E).
33
+ 2. Vives-eaux (coef 95) — 13 U values, ``*``, 13 V values.
34
+ Tenths of a knot. Hour offsets -6h, -5h, ..., 0h, +1h, ..., +6h
35
+ relative to the reference port's high (or low) tide.
36
+ Components positive toward east (U) and north (V).
37
+ 3. Mortes-eaux (coef 45) — same layout.
38
+
39
+ To predict the current at coefficient C, linearly interpolate:
40
+
41
+ V(C) = V_me + (C - 45) / 50 * (V_ve - V_me)
42
+
43
+ Mediterranean coasts are not covered (per SHOM: tidal currents
44
+ negligible there).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from dataclasses import dataclass
50
+ from pathlib import Path
51
+
52
+ import numpy as np
53
+
54
+ # Hours sampled in each VE/ME line, relative to PM/BM at the reference port.
55
+ HOUR_OFFSETS: tuple[int, ...] = tuple(range(-6, 7)) # -6, -5, ..., +6 → 13 values
56
+ # m/s to knots: 1 m/s = 1.94384 kt. SHOM stores values in 1/10 kt so the file
57
+ # integer 25 means 2.5 kt directly — no m/s round-trip needed.
58
+ DECIKT_TO_KN = 0.1
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class C2dPoint:
63
+ """A single SHOM C2D grid point with its 4 hourly time series.
64
+
65
+ All speeds are in knots (converted from the raw 1/10 kt integer storage).
66
+ Components ``u`` are positive toward east, ``v`` positive toward north.
67
+
68
+ The reference convention (``ref_tide``) tells callers whether the 13
69
+ samples are anchored to high tide (``"PM"``) or low tide (``"BM"``) at
70
+ the zone's reference port. Coefficient 45 ≈ mortes-eaux, 95 ≈ vives-eaux.
71
+ """
72
+
73
+ lat: float
74
+ lon: float
75
+ u_ve_kn: tuple[float, ...] # length 13, coef 95
76
+ v_ve_kn: tuple[float, ...]
77
+ u_me_kn: tuple[float, ...] # length 13, coef 45
78
+ v_me_kn: tuple[float, ...]
79
+
80
+ def speed_kn_at(self, hour_offset: int, coef: float) -> float:
81
+ """Speed (kt) at a given hour offset and tide coefficient.
82
+
83
+ ``hour_offset`` must be one of ``HOUR_OFFSETS`` (no time interpolation
84
+ here — keep this primitive small; callers do hour interpolation).
85
+ ``coef`` is linearly interpolated between 45 and 95 (extrapolation
86
+ allowed but flagged by the caller's domain knowledge).
87
+ """
88
+ idx = HOUR_OFFSETS.index(hour_offset)
89
+ u = self.u_me_kn[idx] + (coef - 45.0) / 50.0 * (self.u_ve_kn[idx] - self.u_me_kn[idx])
90
+ v = self.v_me_kn[idx] + (coef - 45.0) / 50.0 * (self.v_ve_kn[idx] - self.v_me_kn[idx])
91
+ return float(np.hypot(u, v))
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class C2dZone:
96
+ """One SHOM C2D zone (one .UJA atlas planche).
97
+
98
+ Attributes:
99
+ atlas_id: SHOM atlas number (557..565).
100
+ name: zone short name from the filename (e.g. ``"MORBIHAN"``).
101
+ ref_port: reference port name as printed in the file header,
102
+ stripped of any ``.BM`` suffix.
103
+ ref_tide: ``"PM"`` (high tide) or ``"BM"`` (low tide); tells callers
104
+ which tide event the hourly samples are anchored to.
105
+ points: tuple of grid points. Order matches the file.
106
+ """
107
+
108
+ atlas_id: int
109
+ name: str
110
+ ref_port: str
111
+ ref_tide: str # "PM" or "BM"
112
+ points: tuple[C2dPoint, ...]
113
+
114
+ @property
115
+ def bbox(self) -> tuple[float, float, float, float]:
116
+ """``(lat_min, lon_min, lat_max, lon_max)`` over the zone's points."""
117
+ lats = [p.lat for p in self.points]
118
+ lons = [p.lon for p in self.points]
119
+ return (min(lats), min(lons), max(lats), max(lons))
120
+
121
+
122
+ def _parse_lat_lon_token(s: str) -> float:
123
+ """Parse a SHOM ``sDDMM.mmm`` token into signed decimal degrees.
124
+
125
+ The text is a numeric string where the integer part is degrees * 100
126
+ plus minutes and the fractional part is decimal minutes. So
127
+ ``"4737.420"`` means 47°37.420'N (= 47 + 37.420/60 ≈ 47.62367°). A
128
+ ``-`` sign on the whole token flips hemisphere.
129
+ """
130
+ val = float(s)
131
+ sign = 1.0 if val >= 0 else -1.0
132
+ val = abs(val)
133
+ degrees = int(val // 100)
134
+ minutes = val - degrees * 100
135
+ return sign * (degrees + minutes / 60.0)
136
+
137
+
138
+ def _parse_hourly_line(line: str) -> tuple[tuple[float, ...], tuple[float, ...]]:
139
+ """Parse one VE or ME line into ``(u[13], v[13])`` arrays in knots.
140
+
141
+ The raw line is fixed-width and may have negative integers concatenated
142
+ without whitespace (e.g. ``-22-10 0``). We split each side of ``*`` on
143
+ a regex-equivalent walk: scan character-by-character, start a new field
144
+ on a sign or digit boundary, terminate on whitespace or sign change.
145
+ """
146
+ if "*" not in line:
147
+ raise ValueError(f"missing '*' separator in C2D line: {line!r}")
148
+ u_part, v_part = line.split("*", 1)
149
+ us = _split_packed_ints(u_part)
150
+ vs = _split_packed_ints(v_part)
151
+ if len(us) != 13 or len(vs) != 13:
152
+ raise ValueError(f"expected 13 U + 13 V values, got {len(us)} + {len(vs)} in {line!r}")
153
+ return (
154
+ tuple(x * DECIKT_TO_KN for x in us),
155
+ tuple(x * DECIKT_TO_KN for x in vs),
156
+ )
157
+
158
+
159
+ def _split_packed_ints(s: str) -> list[int]:
160
+ """Split a SHOM-style packed integer field like ``"-22-10 0 4"``.
161
+
162
+ Whitespace separates fields; a ``-`` sign also starts a new field even
163
+ without preceding whitespace. Empty whitespace-only segments are
164
+ skipped.
165
+ """
166
+ out: list[int] = []
167
+ buf = ""
168
+ for ch in s:
169
+ if ch == "-":
170
+ if buf and buf != "-":
171
+ out.append(int(buf))
172
+ buf = "-"
173
+ elif ch.isspace():
174
+ if buf and buf != "-":
175
+ out.append(int(buf))
176
+ buf = ""
177
+ else:
178
+ buf += ch
179
+ if buf and buf != "-":
180
+ out.append(int(buf))
181
+ return out
182
+
183
+
184
+ def parse_c2d_file(path: Path | str, atlas_id: int) -> C2dZone:
185
+ """Load one SHOM C2D zone file (e.g. ``DONNEES/558/MORBIHAN_558``).
186
+
187
+ The file is Latin-1 encoded (SHOM legacy). The ``atlas_id`` is the
188
+ SHOM atlas number (557..565); callers usually derive it from the
189
+ parent directory name.
190
+ """
191
+ p = Path(path)
192
+ text = p.read_text(encoding="latin-1")
193
+ # Some files use \r\n; splitlines handles both.
194
+ raw_lines = text.splitlines()
195
+ # Drop fully blank trailing lines but preserve interior structure.
196
+ lines = [line for line in raw_lines if line.strip() != ""]
197
+ if not lines:
198
+ raise ValueError(f"empty C2D file: {p}")
199
+
200
+ header = lines[0].strip()
201
+ # ".BM", " BM" or "_BM" suffix → reference is low tide. SHOM mixes the
202
+ # three across files (e.g. "Le Havre.BM" in atlas 561 vs
203
+ # "La_Rochelle_BM" in atlas 559). Otherwise high tide.
204
+ ref_tide = "PM"
205
+ ref_port = header
206
+ upper = header.upper()
207
+ if upper.endswith(".BM") or upper.endswith(" BM") or upper.endswith("_BM"):
208
+ ref_tide = "BM"
209
+ ref_port = header[:-3].strip().rstrip("_").strip()
210
+
211
+ # Each subsequent point is exactly 3 lines: position, VE, ME.
212
+ body = lines[1:]
213
+ if len(body) % 3 != 0:
214
+ raise ValueError(f"C2D body line count not divisible by 3 in {p}: got {len(body)} lines")
215
+
216
+ points: list[C2dPoint] = []
217
+ for i in range(0, len(body), 3):
218
+ pos_line = body[i]
219
+ ve_line = body[i + 1]
220
+ me_line = body[i + 2]
221
+ # Position line: two whitespace-separated tokens, possibly with a
222
+ # trailing tab/space artefact from the original CD-ROM export.
223
+ tokens = pos_line.split()
224
+ if len(tokens) < 2:
225
+ raise ValueError(f"bad C2D position line in {p}: {pos_line!r}")
226
+ lat = _parse_lat_lon_token(tokens[0])
227
+ lon = _parse_lat_lon_token(tokens[1])
228
+ u_ve, v_ve = _parse_hourly_line(ve_line)
229
+ u_me, v_me = _parse_hourly_line(me_line)
230
+ points.append(
231
+ C2dPoint(
232
+ lat=lat,
233
+ lon=lon,
234
+ u_ve_kn=u_ve,
235
+ v_ve_kn=v_ve,
236
+ u_me_kn=u_me,
237
+ v_me_kn=v_me,
238
+ )
239
+ )
240
+
241
+ return C2dZone(
242
+ atlas_id=atlas_id,
243
+ name=p.name.rsplit("_", 1)[0],
244
+ ref_port=ref_port,
245
+ ref_tide=ref_tide,
246
+ points=tuple(points),
247
+ )
248
+
249
+
250
+ def load_c2d_directory(donnees_dir: Path | str) -> tuple[C2dZone, ...]:
251
+ """Load every zone file under a SHOM C2D ``DONNEES`` directory.
252
+
253
+ Walks ``DONNEES/<atlas_id>/<ZONE_NAME>_<atlas_id>`` files and skips
254
+ the ``_lisezmoi_*.txt`` documentation. Returns zones in deterministic
255
+ order (sorted by atlas id then zone name) so downstream code can rely
256
+ on a stable iteration.
257
+ """
258
+ root = Path(donnees_dir)
259
+ zones: list[C2dZone] = []
260
+ for atlas_dir in sorted(root.iterdir()):
261
+ if not atlas_dir.is_dir():
262
+ continue
263
+ try:
264
+ atlas_id = int(atlas_dir.name)
265
+ except ValueError:
266
+ continue
267
+ for zone_file in sorted(atlas_dir.iterdir()):
268
+ if zone_file.name.startswith("_") or not zone_file.is_file():
269
+ continue
270
+ zones.append(parse_c2d_file(zone_file, atlas_id))
271
+ return tuple(zones)
vendor/data-adapters/src/openwind_data/currents/shom_c2d_registry.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime SHOM Atlas C2D registry: spatial index + tide-relative predictor.
2
+
3
+ Loads the Parquet + JSON artefacts produced by ``scripts/build_shom_c2d.py``
4
+ and exposes prediction at any ``(lat, lon, datetime)`` independently of MARC.
5
+
6
+ Pipeline at query time:
7
+
8
+ 1. Test bbox membership (cheap rectangle test). Outside the SHOM bbox, the
9
+ caller falls back to MARC or SMOC.
10
+ 2. KDTree-nearest lookup over the ~13 k scattered points → returns the
11
+ point's 4 series (U/V at vives-eaux 95 and mortes-eaux 45) and its
12
+ reference port key.
13
+ 3. Harmonic prediction at the reference port's M2/S2/N2/K1/O1/M4 constants
14
+ to find the PM (or BM) event nearest to the query time. Yields the
15
+ ``hour_offset`` ∈ [-6, +6] used to linear-interp the 13-sample series.
16
+ 4. Linear interpolation in time over the 13-hour series, twice (coef 45
17
+ and coef 95), and finally a linear interpolation in coefficient based
18
+ on the predicted tide range at the reference port for that day.
19
+
20
+ All harmonic constants live in the JSON file shipped alongside the
21
+ Parquet, so this module never imports MARC at runtime — the MARC
22
+ dependency is purely build-time. If MARC is dropped in a later iteration,
23
+ SHOM C2D keeps working.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from dataclasses import dataclass
30
+ from datetime import UTC, datetime, timedelta
31
+ from pathlib import Path
32
+
33
+ import numpy as np
34
+ import polars as pl
35
+
36
+ from openwind_data.currents.harmonic import predict as harmonic_predict
37
+
38
+ # Hour offsets covered by the SHOM 13-sample series, in hours relative to
39
+ # PM/BM at the reference port.
40
+ _HOUR_OFFSETS = np.arange(-6, 7, dtype=float)
41
+ # Mean-equinox tidal range at Brest (m). SHOM defines coef 100 as
42
+ # 100 x range / 6.1 m. Used here to normalise the day's predicted range
43
+ # into a tidal coefficient.
44
+ _BREST_MEAN_RANGE_M = 6.1
45
+ # How wide a window to scan around the query time when locating a tide
46
+ # event. Slightly wider than half the M2 period (12.42 h) so we always
47
+ # bracket exactly one PM and one BM.
48
+ _TIDE_SCAN_HALFWINDOW = timedelta(hours=7.0)
49
+ # Sampling step inside the scan window (minutes). 5-min step gives < 1 min
50
+ # of error on the located extremum, well below the harmonic resolution.
51
+ _TIDE_SCAN_STEP_MIN = 5
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class _RefPortMeta:
56
+ display_name: str
57
+ lat: float
58
+ lon: float
59
+ ref_tide: str # "PM" or "BM"
60
+ constants: dict[str, tuple[float, float]]
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ShomC2dRegistry:
65
+ """All SHOM C2D points + reference-port constants, indexed for fast lookup.
66
+
67
+ Construct via :meth:`from_directory` once at server startup; callers
68
+ keep a long-lived instance and call :meth:`predict_current_series`
69
+ repeatedly. The struct holds ~5 MB of numpy arrays plus the KDTree.
70
+
71
+ Field semantics:
72
+
73
+ - ``lats`` / ``lons``: WGS84 in degrees, shape ``(N,)`` of float32.
74
+ - ``u_ve`` / ``v_ve`` / ``u_me`` / ``v_me``: shape ``(N, 13)`` float32,
75
+ hour offsets ``-6h..+6h``, in knots. ``ve`` = vives-eaux (coef 95),
76
+ ``me`` = mortes-eaux (coef 45).
77
+ - ``ref_port_keys``: per-point lookup key into ``ref_ports`` (object
78
+ dtype, shape ``(N,)``).
79
+ - ``zone_names``: per-point zone label, e.g. ``"MORBIHAN"``. Used in
80
+ ``current_source`` provenance strings.
81
+ - ``atlas_ids``: per-point SHOM atlas number (557..565), int16.
82
+ - ``ref_ports``: dict ``key → _RefPortMeta`` for tide-event prediction.
83
+ - ``bbox``: ``(lat_min, lon_min, lat_max, lon_max)`` for fast pre-filter.
84
+
85
+ Spatial nearest-neighbour is brute-force vectorised numpy: a single
86
+ query computes squared distance to all ~13 k points (~80 µs) and
87
+ returns the minimum index. A KDTree would be faster asymptotically
88
+ but adds a scipy dependency and saves microseconds we don't need at
89
+ this scale. The per-query cost is dominated by the harmonic
90
+ prediction at the reference port, not by the spatial lookup.
91
+ """
92
+
93
+ lats: np.ndarray # shape (N,), float32
94
+ lons: np.ndarray
95
+ u_ve: np.ndarray # shape (N, 13)
96
+ v_ve: np.ndarray
97
+ u_me: np.ndarray
98
+ v_me: np.ndarray
99
+ ref_port_keys: np.ndarray # shape (N,), object
100
+ zone_names: np.ndarray
101
+ atlas_ids: np.ndarray # shape (N,), int16
102
+ ref_ports: dict[str, _RefPortMeta]
103
+ bbox: tuple[float, float, float, float]
104
+ _cos_mean_lat: float # cached for query-side projection
105
+
106
+ @classmethod
107
+ def from_directory(cls, root: Path | str) -> ShomC2dRegistry:
108
+ """Load the Parquet + JSON pair from a build artefact directory.
109
+
110
+ Returns an empty registry (zero points, ``bbox`` collapsed to
111
+ ``(0, 0, 0, 0)``, an empty tree) if the directory is missing or
112
+ the artefacts are absent. The runtime treats an empty registry as
113
+ "not covered anywhere", so the cascade falls back to MARC / SMOC.
114
+ """
115
+ root = Path(root)
116
+ points_path = root / "shom_c2d_points.parquet"
117
+ ports_path = root / "shom_c2d_ref_ports.json"
118
+ if not points_path.exists() or not ports_path.exists():
119
+ return cls._empty()
120
+
121
+ df = pl.read_parquet(points_path)
122
+ if df.height == 0:
123
+ return cls._empty()
124
+
125
+ lats = df["lat"].to_numpy().astype(np.float32, copy=False)
126
+ lons = df["lon"].to_numpy().astype(np.float32, copy=False)
127
+ u_ve = np.array(df["u_ve_kn"].to_list(), dtype=np.float32)
128
+ v_ve = np.array(df["v_ve_kn"].to_list(), dtype=np.float32)
129
+ u_me = np.array(df["u_me_kn"].to_list(), dtype=np.float32)
130
+ v_me = np.array(df["v_me_kn"].to_list(), dtype=np.float32)
131
+ ref_port_keys = df["ref_port_key"].to_numpy()
132
+ zone_names = df["zone"].to_numpy()
133
+ atlas_ids = df["atlas_id"].to_numpy().astype(np.int16, copy=False)
134
+
135
+ raw_ports = json.loads(ports_path.read_text())
136
+ ref_ports = {
137
+ key: _RefPortMeta(
138
+ display_name=v["display_name"],
139
+ lat=float(v["lat"]),
140
+ lon=float(v["lon"]),
141
+ ref_tide=str(v["ref_tide"]),
142
+ constants={k: (float(amp), float(g)) for k, (amp, g) in v["constants"].items()},
143
+ )
144
+ for key, v in raw_ports.items()
145
+ }
146
+
147
+ cos_mean_lat = float(np.cos(np.deg2rad(lats.mean())))
148
+ bbox = (
149
+ float(lats.min()),
150
+ float(lons.min()),
151
+ float(lats.max()),
152
+ float(lons.max()),
153
+ )
154
+ return cls(
155
+ lats=lats,
156
+ lons=lons,
157
+ u_ve=u_ve,
158
+ v_ve=v_ve,
159
+ u_me=u_me,
160
+ v_me=v_me,
161
+ ref_port_keys=ref_port_keys,
162
+ zone_names=zone_names,
163
+ atlas_ids=atlas_ids,
164
+ ref_ports=ref_ports,
165
+ bbox=bbox,
166
+ _cos_mean_lat=cos_mean_lat,
167
+ )
168
+
169
+ @classmethod
170
+ def _empty(cls) -> ShomC2dRegistry:
171
+ return cls(
172
+ lats=np.zeros(0, dtype=np.float32),
173
+ lons=np.zeros(0, dtype=np.float32),
174
+ u_ve=np.zeros((0, 13), dtype=np.float32),
175
+ v_ve=np.zeros((0, 13), dtype=np.float32),
176
+ u_me=np.zeros((0, 13), dtype=np.float32),
177
+ v_me=np.zeros((0, 13), dtype=np.float32),
178
+ ref_port_keys=np.zeros(0, dtype=object),
179
+ zone_names=np.zeros(0, dtype=object),
180
+ atlas_ids=np.zeros(0, dtype=np.int16),
181
+ ref_ports={},
182
+ bbox=(0.0, 0.0, 0.0, 0.0),
183
+ _cos_mean_lat=1.0,
184
+ )
185
+
186
+ # ------------------------------------------------------------------
187
+ # Spatial coverage
188
+ # ------------------------------------------------------------------
189
+
190
+ # Maximum acceptable distance (km) between a query point and the nearest
191
+ # SHOM C2D point for us to claim coverage. Beyond this, the query
192
+ # falls back through the cascade — even though the bbox might still
193
+ # contain it, the SHOM zone is too sparse to make the value meaningful.
194
+ _MAX_NEAREST_KM = 5.0
195
+
196
+ # Tolerance applied to the bbox short-circuit so float32-derived bbox
197
+ # bounds don't reject queries that sit exactly on the edge of the
198
+ # cloud. ~0.01° ≈ 1 km, well below the nearest-point distance gate.
199
+ _BBOX_SLACK_DEG = 0.01
200
+
201
+ def covers(self, lat: float, lon: float) -> bool:
202
+ """Whether SHOM C2D has a point within ``_MAX_NEAREST_KM`` of (lat, lon).
203
+
204
+ SHOM C2D is a scattered point cloud, not a regular grid: a query
205
+ can fall well inside the bbox of the Morbihan cartouche yet sit
206
+ on land or in a region SHOM didn't sample. The bbox test alone
207
+ would over-claim. We pair it with a real distance check.
208
+ """
209
+ if not self.lats.size:
210
+ return False
211
+ lat_min, lon_min, lat_max, lon_max = self.bbox
212
+ s = self._BBOX_SLACK_DEG
213
+ if not (lat_min - s <= lat <= lat_max + s and lon_min - s <= lon <= lon_max + s):
214
+ return False
215
+ idx, dist_km = self._nearest(lat, lon)
216
+ return idx is not None and dist_km <= self._MAX_NEAREST_KM
217
+
218
+ def _nearest(self, lat: float, lon: float) -> tuple[int | None, float]:
219
+ """Index of the nearest C2D point + distance in km, or ``(None, inf)``.
220
+
221
+ Brute-force vectorised distance over the full point set in a
222
+ local-tangent-plane projection (degrees-lon scaled by mean
223
+ ``cos(lat)`` so the metric is roughly isotropic in km). At ~13 k
224
+ points this runs in ~80 µs per query; no spatial index needed.
225
+ """
226
+ if not self.lats.size:
227
+ return None, float("inf")
228
+ dlat = self.lats - lat
229
+ dlon = (self.lons - lon) * self._cos_mean_lat
230
+ d2 = dlat * dlat + dlon * dlon # squared distance in scaled degrees
231
+ idx = int(np.argmin(d2))
232
+ d_deg = float(np.sqrt(d2[idx]))
233
+ # 1° in our scaled space ≈ 111 km on the ground (lat scale dominant).
234
+ return idx, d_deg * 111.0
235
+
236
+ # ------------------------------------------------------------------
237
+ # Tide-event helpers (PM / BM at reference ports)
238
+ # ------------------------------------------------------------------
239
+
240
+ def _tide_event_time(self, port: _RefPortMeta, target_t: datetime) -> datetime:
241
+ """Find the nearest ``port.ref_tide`` event (PM or BM) to ``target_t``.
242
+
243
+ Sample tide height every 5 min over a ±7 h window around target_t,
244
+ spot the global maximum (PM) or minimum (BM) within that window
245
+ — a 14 h span exceeds the M2 period (12.42 h) so it always
246
+ contains exactly one event of each type. Returns a UTC datetime.
247
+
248
+ The approach is brute force on purpose: vectorised over ~170
249
+ sample points x 6 constituents = ~1000 cosines, well under a
250
+ millisecond per call. No bisection required.
251
+ """
252
+ if target_t.tzinfo is None:
253
+ target_t = target_t.replace(tzinfo=UTC)
254
+ n_steps = int(2 * _TIDE_SCAN_HALFWINDOW.total_seconds() / 60 / _TIDE_SCAN_STEP_MIN) + 1
255
+ offsets_min = np.linspace(
256
+ -_TIDE_SCAN_HALFWINDOW.total_seconds() / 60,
257
+ _TIDE_SCAN_HALFWINDOW.total_seconds() / 60,
258
+ n_steps,
259
+ )
260
+ scan_times = [target_t + timedelta(minutes=float(m)) for m in offsets_min]
261
+ heights = harmonic_predict(scan_times, port.constants)
262
+ idx = int(np.argmax(heights) if port.ref_tide == "PM" else np.argmin(heights))
263
+ return scan_times[idx]
264
+
265
+ def _coefficient_for_day(self, port: _RefPortMeta, target_t: datetime) -> float:
266
+ """Approximate tidal coefficient at the reference port for the day.
267
+
268
+ Predict the tide over a 25 h window centred on target_t and read
269
+ ``range = max - min``. Coef = 100 x range / 6.1 m, clamped to
270
+ [20, 120] (SHOM's documented range). The 6.1 m normalisation is
271
+ Brest's mean-equinox spring range, which is the standard
272
+ denominator for the French tidal coefficient regardless of port.
273
+ """
274
+ if target_t.tzinfo is None:
275
+ target_t = target_t.replace(tzinfo=UTC)
276
+ # 25 h window with 30-min step covers two semi-diurnal cycles.
277
+ offsets_min = np.linspace(-12.5 * 60, 12.5 * 60, 51)
278
+ scan_times = [target_t + timedelta(minutes=float(m)) for m in offsets_min]
279
+ heights = harmonic_predict(scan_times, port.constants)
280
+ rng = float(heights.max() - heights.min())
281
+ coef = 100.0 * rng / _BREST_MEAN_RANGE_M
282
+ return max(20.0, min(120.0, coef))
283
+
284
+ # ------------------------------------------------------------------
285
+ # Public predictor
286
+ # ------------------------------------------------------------------
287
+
288
+ def predict_current_series(
289
+ self, lat: float, lon: float, times: list[datetime]
290
+ ) -> tuple[np.ndarray, np.ndarray, str] | None:
291
+ """Predict (speeds_kn, dirs_to_deg, source_label) at (lat, lon) for ``times``.
292
+
293
+ Returns ``None`` when the query point is outside SHOM coverage
294
+ (caller falls back to MARC / SMOC). The source label embeds the
295
+ atlas id and zone name so downstream code can attribute the value,
296
+ e.g. ``"shom_c2d_558_morbihan"``.
297
+
298
+ The prediction is per-time independent: each query time gets its
299
+ own nearest tide event and its own day's coefficient. This costs
300
+ a handful of harmonic predictions per series and keeps the code
301
+ simple; if ever the call rate justifies it, a vectorised
302
+ per-series optimisation is straightforward.
303
+ """
304
+ idx, dist_km = self._nearest(lat, lon)
305
+ if idx is None or dist_km > self._MAX_NEAREST_KM:
306
+ return None
307
+
308
+ port_key = str(self.ref_port_keys[idx])
309
+ port = self.ref_ports.get(port_key)
310
+ if port is None:
311
+ return None # build artefact mismatch — fail closed
312
+
313
+ u_ve = self.u_ve[idx]
314
+ v_ve = self.v_ve[idx]
315
+ u_me = self.u_me[idx]
316
+ v_me = self.v_me[idx]
317
+ atlas_id = int(self.atlas_ids[idx])
318
+ zone = str(self.zone_names[idx])
319
+ source_label = f"shom_c2d_{atlas_id}_{zone.lower()}"
320
+
321
+ speeds = np.empty(len(times), dtype=np.float32)
322
+ dirs = np.empty(len(times), dtype=np.float32)
323
+ for i, t in enumerate(times):
324
+ event_t = self._tide_event_time(port, t)
325
+ offset_h = (t - event_t).total_seconds() / 3600.0
326
+ # Clamp to the sampled range; np.interp already clips at the
327
+ # ends, but clamping explicitly keeps the intent clear.
328
+ offset_h = max(-6.0, min(6.0, offset_h))
329
+ u_ve_t = float(np.interp(offset_h, _HOUR_OFFSETS, u_ve))
330
+ v_ve_t = float(np.interp(offset_h, _HOUR_OFFSETS, v_ve))
331
+ u_me_t = float(np.interp(offset_h, _HOUR_OFFSETS, u_me))
332
+ v_me_t = float(np.interp(offset_h, _HOUR_OFFSETS, v_me))
333
+ coef = self._coefficient_for_day(port, t)
334
+ w = (coef - 45.0) / 50.0
335
+ u = u_me_t + w * (u_ve_t - u_me_t)
336
+ v = v_me_t + w * (v_ve_t - v_me_t)
337
+ speeds[i] = float(np.hypot(u, v))
338
+ # Convert (u east, v north) to compass "to" direction.
339
+ dirs[i] = float(np.rad2deg(np.arctan2(u, v)) % 360.0)
340
+ return speeds, dirs, source_label
vendor/data-adapters/src/openwind_data/routing/passage.py CHANGED
@@ -36,6 +36,7 @@ from openwind_data.adapters.openmeteo import (
36
  DEFAULT_MODEL,
37
  OpenMeteoAdapter,
38
  )
 
39
  from openwind_data.routing.archetypes import BoatPolar, get_polar, lookup_polar
40
  from openwind_data.routing.geometry import (
41
  Point,
@@ -182,6 +183,13 @@ class SegmentReport:
182
  # ``"marc_finis_250m"``) when MARC PREVIMER atlas data overrides Open-Meteo
183
  # in covered zones. ``None`` when no current data is available.
184
  current_source: str | None = None
 
 
 
 
 
 
 
185
  gust_kn: float | None = None
186
  wave_period_s: float | None = None
187
 
@@ -387,7 +395,7 @@ async def _estimate_with_model(
387
  reports: list[SegmentReport] = []
388
  cumulative_actual = timedelta(0)
389
  min_boat_speed = float("inf")
390
- for seg, mid_time, _mid_pt, bundle in zip(
391
  segments, seg_mid_times, seg_mid_points, bundles, strict=True
392
  ):
393
  wind_series = bundle.wind_by_model.get(model)
@@ -412,6 +420,7 @@ async def _estimate_with_model(
412
  cur_kn = sea_pt.current_speed_kn if sea_pt else None
413
  cur_to = sea_pt.current_direction_to_deg if sea_pt else None
414
  cur_src = sea_pt.current_source if sea_pt else None
 
415
  derate = 1.0
416
  if use_wave_correction and hs_m is not None:
417
  derate = wave_derate(hs_m, twa)
@@ -443,6 +452,7 @@ async def _estimate_with_model(
443
  current_direction_to_deg=cur_to,
444
  sog_kn=sog,
445
  current_source=cur_src,
 
446
  gust_kn=wp.gust_kn,
447
  wave_period_s=tp_s,
448
  )
@@ -533,8 +543,12 @@ async def _estimate_backward_with_model(
533
  reverse_reports: list[SegmentReport] = []
534
  end_time = target_utc
535
  min_boat_speed = float("inf")
536
- for seg, mid_time, bundle in zip(
537
- reversed(segments), reversed(seg_mid_times), reversed(bundles), strict=True
 
 
 
 
538
  ):
539
  wind_series = bundle.wind_by_model.get(model)
540
  if wind_series is None or not wind_series.points:
@@ -553,6 +567,7 @@ async def _estimate_backward_with_model(
553
  cur_kn = sea_pt.current_speed_kn if sea_pt else None
554
  cur_to = sea_pt.current_direction_to_deg if sea_pt else None
555
  cur_src = sea_pt.current_source if sea_pt else None
 
556
  derate = 1.0
557
  if use_wave_correction and hs_m is not None:
558
  derate = wave_derate(hs_m, twa)
@@ -582,6 +597,7 @@ async def _estimate_backward_with_model(
582
  current_source=cur_src,
583
  current_direction_to_deg=cur_to,
584
  sog_kn=sog,
 
585
  gust_kn=wp.gust_kn,
586
  wave_period_s=tp_s,
587
  )
 
36
  DEFAULT_MODEL,
37
  OpenMeteoAdapter,
38
  )
39
+ from openwind_data.currents.narrow_pass import confidence_for_point
40
  from openwind_data.routing.archetypes import BoatPolar, get_polar, lookup_polar
41
  from openwind_data.routing.geometry import (
42
  Point,
 
183
  # ``"marc_finis_250m"``) when MARC PREVIMER atlas data overrides Open-Meteo
184
  # in covered zones. ``None`` when no current data is available.
185
  current_source: str | None = None
186
+ # Qualitative confidence in the current/tide value: ``"high"`` (MARC 250 m
187
+ # to 2 km, in coverage), ``"medium"`` (Open-Meteo SMOC 8 km global), or
188
+ # ``"low"`` (waypoint falls inside a known narrow tidal pass where every
189
+ # open product under-resolves the choke — Goulet de Brest, Raz de Sein,
190
+ # Fromveur, Goulet du Morbihan, Téignouse, Raz Blanchard, Raz de Barfleur,
191
+ # Chenal du Four). ``None`` when no current data is available.
192
+ current_confidence: str | None = None
193
  gust_kn: float | None = None
194
  wave_period_s: float | None = None
195
 
 
395
  reports: list[SegmentReport] = []
396
  cumulative_actual = timedelta(0)
397
  min_boat_speed = float("inf")
398
+ for seg, mid_time, mid_pt, bundle in zip(
399
  segments, seg_mid_times, seg_mid_points, bundles, strict=True
400
  ):
401
  wind_series = bundle.wind_by_model.get(model)
 
420
  cur_kn = sea_pt.current_speed_kn if sea_pt else None
421
  cur_to = sea_pt.current_direction_to_deg if sea_pt else None
422
  cur_src = sea_pt.current_source if sea_pt else None
423
+ cur_conf = confidence_for_point(mid_pt.lat, mid_pt.lon, cur_src)
424
  derate = 1.0
425
  if use_wave_correction and hs_m is not None:
426
  derate = wave_derate(hs_m, twa)
 
452
  current_direction_to_deg=cur_to,
453
  sog_kn=sog,
454
  current_source=cur_src,
455
+ current_confidence=cur_conf,
456
  gust_kn=wp.gust_kn,
457
  wave_period_s=tp_s,
458
  )
 
543
  reverse_reports: list[SegmentReport] = []
544
  end_time = target_utc
545
  min_boat_speed = float("inf")
546
+ for seg, mid_time, mid_pt, bundle in zip(
547
+ reversed(segments),
548
+ reversed(seg_mid_times),
549
+ reversed(seg_mid_points),
550
+ reversed(bundles),
551
+ strict=True,
552
  ):
553
  wind_series = bundle.wind_by_model.get(model)
554
  if wind_series is None or not wind_series.points:
 
567
  cur_kn = sea_pt.current_speed_kn if sea_pt else None
568
  cur_to = sea_pt.current_direction_to_deg if sea_pt else None
569
  cur_src = sea_pt.current_source if sea_pt else None
570
+ cur_conf = confidence_for_point(mid_pt.lat, mid_pt.lon, cur_src)
571
  derate = 1.0
572
  if use_wave_correction and hs_m is not None:
573
  derate = wave_derate(hs_m, twa)
 
597
  current_source=cur_src,
598
  current_direction_to_deg=cur_to,
599
  sog_kn=sog,
600
+ current_confidence=cur_conf,
601
  gust_kn=wp.gust_kn,
602
  wave_period_s=tp_s,
603
  )
vendor/mcp-core/src/openwind_mcp_core/server.py CHANGED
@@ -41,6 +41,7 @@ from openwind_data.adapters.base import MarineDataAdapter
41
  from openwind_data.adapters.openmeteo import AUTO_MODEL, OpenMeteoAdapter
42
  from openwind_data.currents.marc_atlas import MarcAtlasRegistry
43
  from openwind_data.currents.router import CompositeMarineAdapter
 
44
  from openwind_data.routing import (
45
  Point,
46
  _build_conditions_summary,
@@ -406,6 +407,13 @@ uses unless overridden by tool parameters.
406
  captures globally. Even the MARC atlases do not replace a SHOM tide
407
  atlas or paper chart for fine navigation in a narrow pass.
408
 
 
 
 
 
 
 
 
409
  - Minimum boat speed / SOG: 0.5 kn floor to avoid blow-up in extreme
410
  stalls or strongly opposing currents.
411
 
@@ -493,10 +501,13 @@ def build_server(*, adapter: MarineDataAdapter | None = None) -> FastMCP:
493
 
494
  Args:
495
  adapter: optional `MarineDataAdapter` used by data-fetching tools.
496
- Defaults to a `CompositeMarineAdapter` that wraps a fresh
497
- `OpenMeteoAdapter` and the MARC PREVIMER atlases discovered
498
- under ``MARC_ATLAS_DIR`` (or falls back to Open-Meteo only when
499
- no MARC dataset is available locally). Override in tests.
 
 
 
500
  """
501
  server: FastMCP = FastMCP("openwind")
502
  if adapter is not None:
@@ -504,13 +515,23 @@ def build_server(*, adapter: MarineDataAdapter | None = None) -> FastMCP:
504
  else:
505
  upstream = OpenMeteoAdapter()
506
  marc_dir = os.environ.get("MARC_ATLAS_DIR")
507
- if marc_dir:
508
- registry = MarcAtlasRegistry.from_directory(marc_dir)
509
- if registry.atlases:
510
- fetch_adapter = CompositeMarineAdapter(upstream=upstream, marc=registry)
511
- else:
512
- fetch_adapter = upstream
 
 
 
 
 
513
  else:
 
 
 
 
 
514
  fetch_adapter = upstream
515
 
516
  @server.resource(
 
41
  from openwind_data.adapters.openmeteo import AUTO_MODEL, OpenMeteoAdapter
42
  from openwind_data.currents.marc_atlas import MarcAtlasRegistry
43
  from openwind_data.currents.router import CompositeMarineAdapter
44
+ from openwind_data.currents.shom_c2d_registry import ShomC2dRegistry
45
  from openwind_data.routing import (
46
  Point,
47
  _build_conditions_summary,
 
407
  captures globally. Even the MARC atlases do not replace a SHOM tide
408
  atlas or paper chart for fine navigation in a narrow pass.
409
 
410
+ - Current confidence (``current_confidence`` per leg): qualitative tag
411
+ derived from the data source. ``"high"`` on SHOM Atlas C2D and MARC
412
+ PREVIMER (regional harmonic atlases); ``"medium"`` on Open-Meteo SMOC
413
+ (8 km global product); ``None`` when no current data is available.
414
+ A data-driven downgrade in choke points (zones where SHOM C2D peaks
415
+ exceed ~3 kt) will land with the C2D adapter.
416
+
417
  - Minimum boat speed / SOG: 0.5 kn floor to avoid blow-up in extreme
418
  stalls or strongly opposing currents.
419
 
 
501
 
502
  Args:
503
  adapter: optional `MarineDataAdapter` used by data-fetching tools.
504
+ Defaults to a ``CompositeMarineAdapter`` that wraps a fresh
505
+ ``OpenMeteoAdapter`` and stacks two coastal-detail sources on
506
+ top: the SHOM Atlas C2D registry under ``SHOM_C2D_DIR`` (when
507
+ built and shipped), and the MARC PREVIMER atlases under
508
+ ``MARC_ATLAS_DIR``. Either or both can be absent; the cascade
509
+ degrades gracefully (SHOM > MARC > SMOC). Override the whole
510
+ adapter in tests.
511
  """
512
  server: FastMCP = FastMCP("openwind")
513
  if adapter is not None:
 
515
  else:
516
  upstream = OpenMeteoAdapter()
517
  marc_dir = os.environ.get("MARC_ATLAS_DIR")
518
+ shom_dir = os.environ.get("SHOM_C2D_DIR")
519
+ marc_registry = MarcAtlasRegistry.from_directory(marc_dir) if marc_dir else None
520
+ shom_registry = ShomC2dRegistry.from_directory(shom_dir) if shom_dir else None
521
+ marc_available = marc_registry is not None and bool(marc_registry.atlases)
522
+ shom_available = shom_registry is not None and shom_registry.lats.size > 0
523
+ if marc_available:
524
+ fetch_adapter = CompositeMarineAdapter(
525
+ upstream=upstream,
526
+ marc=marc_registry, # type: ignore[arg-type]
527
+ shom=shom_registry if shom_available else None,
528
+ )
529
  else:
530
+ # Without MARC the composite adapter has nothing to override
531
+ # currents with on the shelf; we keep upstream Open-Meteo only.
532
+ # (SHOM alone in the composite would still work, but mixing
533
+ # SHOM-only zones with raw SMOC elsewhere is more readable
534
+ # via the existing two-tier composite once MARC lands.)
535
  fetch_adapter = upstream
536
 
537
  @server.resource(