Spaces:
Sleeping
Sleeping
openwind-ci commited on
Commit ·
e904b2e
1
Parent(s): 522ef7c
sync: github 3e9a578
Browse files- Dockerfile +13 -9
- fetch_atlases.py +68 -0
- fetch_marc_dataset.py +0 -40
- vendor/data-adapters/src/openwind_data/currents/narrow_pass.py +52 -0
- vendor/data-adapters/src/openwind_data/currents/router.py +79 -10
- vendor/data-adapters/src/openwind_data/currents/shom_c2d.py +271 -0
- vendor/data-adapters/src/openwind_data/currents/shom_c2d_registry.py +340 -0
- vendor/data-adapters/src/openwind_data/routing/passage.py +19 -3
- vendor/mcp-core/src/openwind_mcp_core/server.py +31 -10
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
|
| 19 |
-
# need an HF token with read access. On HF
|
| 20 |
-
# Settings -> Variables and secrets -> Secrets:
|
| 21 |
# HF_TOKEN (User Access Token with read scope on Qdonnars/openwind-tidal-atlas)
|
| 22 |
-
# Runtime reads
|
| 23 |
-
#
|
| 24 |
-
#
|
|
|
|
|
|
|
| 25 |
ENV HF_DATASET_ID=Qdonnars/openwind-tidal-atlas
|
| 26 |
-
ENV
|
| 27 |
-
|
|
|
|
|
|
|
| 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/
|
| 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 —
|
| 2 |
|
| 3 |
-
Wraps an upstream ``MarineDataAdapter`` (typically ``OpenMeteoAdapter``)
|
| 4 |
-
a ``MarcAtlasRegistry``
|
| 5 |
-
has currents and tide heights
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Provenance is exposed on each ``SeaPoint`` via ``current_source``:
|
| 10 |
-
``"
|
| 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
|
|
|
|
| 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,
|
| 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),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|