forcingkit 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {forcingkit-0.8.0 → forcingkit-0.9.0}/PKG-INFO +2 -1
  2. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/conf.py +1 -1
  3. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/index.rst +1 -0
  4. forcingkit-0.9.0/docs/source/mesh_frames.rst +89 -0
  5. {forcingkit-0.8.0 → forcingkit-0.9.0}/pyproject.toml +2 -1
  6. forcingkit-0.9.0/src/forcingkit/mesh_frames.py +228 -0
  7. forcingkit-0.9.0/src/forcingkit/ramps/currents_lis.csv +70 -0
  8. forcingkit-0.9.0/src/forcingkit/ramps/currents_lis_dark.csv +69 -0
  9. forcingkit-0.9.0/src/forcingkit/render.py +487 -0
  10. forcingkit-0.9.0/tests/integration/test_mesh_frames_necofs.py +32 -0
  11. forcingkit-0.9.0/tests/unit/test_mesh_frames.py +383 -0
  12. {forcingkit-0.8.0 → forcingkit-0.9.0}/uv.lock +3 -1
  13. {forcingkit-0.8.0 → forcingkit-0.9.0}/.agents/forcingkit.md +0 -0
  14. {forcingkit-0.8.0 → forcingkit-0.9.0}/.claude/CLAUDE.md +0 -0
  15. {forcingkit-0.8.0 → forcingkit-0.9.0}/.env.template +0 -0
  16. {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/copilot-instructions.md +0 -0
  17. {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/docs.yml +0 -0
  18. {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/publish.yml +0 -0
  19. {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/tests.yml +0 -0
  20. {forcingkit-0.8.0 → forcingkit-0.9.0}/.gitignore +0 -0
  21. {forcingkit-0.8.0 → forcingkit-0.9.0}/.markdownlint.yaml +0 -0
  22. {forcingkit-0.8.0 → forcingkit-0.9.0}/.pre-commit-config.yaml +0 -0
  23. {forcingkit-0.8.0 → forcingkit-0.9.0}/.python-version +0 -0
  24. {forcingkit-0.8.0 → forcingkit-0.9.0}/AGENTS.md +0 -0
  25. {forcingkit-0.8.0 → forcingkit-0.9.0}/CONTRIBUTING.md +0 -0
  26. {forcingkit-0.8.0 → forcingkit-0.9.0}/Dockerfile +0 -0
  27. {forcingkit-0.8.0 → forcingkit-0.9.0}/LICENSE +0 -0
  28. {forcingkit-0.8.0 → forcingkit-0.9.0}/README.md +0 -0
  29. {forcingkit-0.8.0 → forcingkit-0.9.0}/docker-compose.yml +0 -0
  30. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/Makefile +0 -0
  31. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/make.bat +0 -0
  32. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/requirements-docs.txt +0 -0
  33. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_extra/CNAME +0 -0
  34. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_static/custom.css +0 -0
  35. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  36. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/atmospheric_forcing.rst +0 -0
  37. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/fetchers.rst +0 -0
  38. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/hycom.rst +0 -0
  39. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/necofs.rst +0 -0
  40. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/nyofs.rst +0 -0
  41. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/rivers.rst +0 -0
  42. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/roadmap.rst +0 -0
  43. {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/sources.rst +0 -0
  44. {forcingkit-0.8.0 → forcingkit-0.9.0}/main.py +0 -0
  45. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/__init__.py +0 -0
  46. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/__init__.py +0 -0
  47. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/main.py +0 -0
  48. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  49. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  50. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/removed.py +0 -0
  51. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/viewer.py +0 -0
  52. {forcingkit-0.8.0 → forcingkit-0.9.0}/service/run_server.py +0 -0
  53. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/__init__.py +0 -0
  54. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
  55. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/dispatcher.py +0 -0
  56. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/coastwatch.py +0 -0
  57. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  58. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/erddap.py +0 -0
  59. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  60. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  61. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr_points.py +0 -0
  62. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hycom.py +0 -0
  63. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  64. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  65. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/necofs.py +0 -0
  66. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/noaa.py +0 -0
  67. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  68. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/usgs.py +0 -0
  69. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
  70. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/settings.py +0 -0
  71. {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/zarr_stream.py +0 -0
  72. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/app.js +0 -0
  73. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/favicon.ico +0 -0
  74. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/favicon.svg +0 -0
  75. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/index.html +0 -0
  76. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/logo.svg +0 -0
  77. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/preview3d.js +0 -0
  78. {forcingkit-0.8.0 → forcingkit-0.9.0}/static/styles.css +0 -0
  79. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/__init__.py +0 -0
  80. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_auth.py +0 -0
  81. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_erddap_fetch.py +0 -0
  82. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  83. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_obc_offshore_nj.py +0 -0
  84. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_ofs_archive_paths.py +0 -0
  85. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  86. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_dem.py +0 -0
  87. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_grib.py +0 -0
  88. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_zarr.py +0 -0
  89. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_coastwatch.py +0 -0
  90. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_dbofs.py +0 -0
  91. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_dispatcher.py +0 -0
  92. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_erddap.py +0 -0
  93. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_atm.py +0 -0
  94. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_idx.py +0 -0
  95. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_points.py +0 -0
  96. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hycom.py +0 -0
  97. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_main.py +0 -0
  98. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_ndbc.py +0 -0
  99. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_necofs_parent.py +0 -0
  100. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_noaa_currents.py +0 -0
  101. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_noaa_datum.py +0 -0
  102. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_nyofs.py +0 -0
  103. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_obc_donor_report.py +0 -0
  104. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_obc_pipeline.py +0 -0
  105. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_removed_routes.py +0 -0
  106. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_settings.py +0 -0
  107. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_usgs.py +0 -0
  108. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_usgs_rivers.py +0 -0
  109. {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_zarr_stream.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forcingkit
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
5
5
  Project-URL: Documentation, https://forcingkit.docs.lhzn.io
6
6
  Project-URL: Issues, https://github.com/lhzn-io/forcingkit/issues
@@ -226,6 +226,7 @@ Requires-Dist: h5py>=3.16.0
226
226
  Requires-Dist: httpx>=0.28.1
227
227
  Requires-Dist: jinja2>=3.0.0
228
228
  Requires-Dist: matplotlib>=3.10.9
229
+ Requires-Dist: pillow>=10.0
229
230
  Requires-Dist: pydantic>=2.12.5
230
231
  Requires-Dist: pydap>=3.5.8
231
232
  Requires-Dist: pyproj>=3.7.2
@@ -9,7 +9,7 @@ sys.path.insert(0, os.path.abspath("../../src"))
9
9
  project = "forcingkit"
10
10
  copyright = "2026, Long Horizon Observatory"
11
11
  author = "Daniel Fry"
12
- release = "0.8.0"
12
+ release = "0.9.0"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -166,5 +166,6 @@ PyPI as ``forcingkit``.
166
166
  :maxdepth: 1
167
167
  :caption: Reference
168
168
 
169
+ mesh_frames
169
170
  sources
170
171
  roadmap
@@ -0,0 +1,89 @@
1
+ Mesh frames: native-mesh image frames
2
+ =====================================
3
+
4
+ ``forcingkit.mesh_frames`` reads model output on the model's own unstructured mesh (no
5
+ regridding) and ``forcingkit.render`` draws it as a sequence of PNG frames: one flat colour per
6
+ triangle, with an optional mesh-edge overlay. NECOFS FVCOM GOM7 surface currents are the first
7
+ registered model.
8
+
9
+ Data contract
10
+ -------------
11
+
12
+ ``MeshFrames`` holds ``times`` (UTC), ``node_lon`` and ``node_lat``, ``tri`` (``n_tri`` x 3,
13
+ zero-based node indices) and ``u``, ``v`` (``[t, n_tri]``, one value per element, NaN where an
14
+ element has no value). Any triangle-mesh model fits it; an adapter is one function registered with
15
+ ``@register_adapter("name")`` that takes ``(bbox, times)`` and returns a ``MeshFrames``. The
16
+ ``forcingkit.mesh_frames`` module docstring shows the template.
17
+
18
+ Projection
19
+ ----------
20
+
21
+ Pixels are plain longitude/latitude, defined exactly by the caller's box ``(W, S, E, N)`` and image
22
+ size ``(w, h)``: pixel column ``i`` spans longitudes ``W + [i, i+1) * (E - W) / w`` and row ``j``
23
+ spans latitudes ``N - [j, j+1) * (N - S) / h``. A pixel takes the colour of the element containing
24
+ its centre, so frames drawn with the same box and size line up pixel for pixel with any other
25
+ renderer that uses the same convention. Outside the mesh (and wherever an element is NaN) is
26
+ transparent, or the background colour when one is given.
27
+
28
+ The mesh is rasterised once per (mesh, box, size) into an element-index image and the edges once
29
+ into an overlay; both are cached, and each frame is a colour lookup per element and per pixel. On a
30
+ synthetic mesh of 52 000 elements, 45 frames at 1600 x 900 render in about 0.6 s plus 0.5 s for the
31
+ index image; writing the PNGs takes about 4.5 s.
32
+
33
+ Colour
34
+ ------
35
+
36
+ ``color`` is any vectorised function from speed (m/s, shape ``(n,)``) to uint8 RGB or RGBA
37
+ (``(n, 3)`` or ``(n, 4)``). Use a fixed scale so a colour means the same speed in every frame:
38
+
39
+ - ``default_color``: viridis, linear over 0 to 1.5 m/s;
40
+ - ``named_color(name, vmin, vmax, log=False, floor=None)``: a built-in ramp (``viridis``,
41
+ ``greys``) over a chosen range;
42
+ - ``lut_color(rows, log=False, floor=None)``: a lookup table of ``(value, r, g, b[, a])`` rows.
43
+ Values below the first row, or below ``floor``, take the first colour; between rows each channel
44
+ is interpolated linearly in the value, or in log(value) when ``log`` is true; values above the
45
+ last row take the last colour; NaN is transparent. ``load_lut_csv(path)`` reads such a table
46
+ from a ``value,r,g,b[,a]`` CSV;
47
+ - ``packaged_color("currents_lis")``: the surface-current table shipped with the package
48
+ (``forcingkit/ramps/currents_lis.csv``), 0.02 to 3.1 m/s on a log axis, pale blue through
49
+ yellow to dark green with CIELAB lightness falling monotonically. Its CSV header records the
50
+ knots and their source;
51
+ - ``packaged_color("currents_lis_dark")``: its twin for a dark basemap, on the same speeds, from
52
+ midnight blue through steel blue to pale seafoam with lightness rising monotonically, so the
53
+ two swap by theme alone.
54
+
55
+ Python
56
+ ------
57
+
58
+ .. code-block:: python
59
+
60
+ from forcingkit.mesh_frames import mesh_frames
61
+ from forcingkit.render import lut_color, render_frames, write_frames
62
+
63
+ bbox = (-73.0, 40.95, -72.6, 41.15) # central Long Island Sound
64
+ frames = mesh_frames("necofs", bbox, "2026-10-15T22:00Z", "2026-10-16T02:00Z", stride_h=1)
65
+ color = lut_color([(0.05, 230, 240, 250), (0.5, 120, 160, 200), (2.0, 20, 40, 90)], log=True)
66
+ images = render_frames(
67
+ frames, bbox, (1200, 800), color,
68
+ edges=((0, 0, 0, 40), 1), background=(242, 243, 240, 255),
69
+ )
70
+ write_frames(images, "frames/", times=frames.times)
71
+
72
+ NECOFS hours are read from the SMAST daily history file that holds each one (falling back to the
73
+ rolling forecast), so a window may cross several archive days.
74
+
75
+ Command line
76
+ ------------
77
+
78
+ .. code-block:: bash
79
+
80
+ uv run python -m forcingkit.render --model necofs \
81
+ --bbox -73.0 40.95 -72.6 41.15 \
82
+ --start 2026-10-15T22:00Z --end 2026-10-16T02:00Z --stride-h 1 \
83
+ --size 1200x800 --ramp viridis --vmax 1.5 \
84
+ --edges 0,0,0,40 --background 242,243,240 --out frames/
85
+
86
+ ``--ramp`` takes a built-in name, a packaged table (``currents_lis``, ``currents_lis_dark``) or a path to a
87
+ ``value,r,g,b[,a]`` CSV (with ``--log`` and ``--floor``). The output directory receives
88
+ ``frame_0000.png``, ``frame_0001.png``, ... and ``frames.json``, which lists the frame times, the box
89
+ and the size.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forcingkit"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -43,6 +43,7 @@ dependencies = [
43
43
  "dask[array]>=2026.3.0",
44
44
  "cftime>=1.6.5",
45
45
  "matplotlib>=3.10.9",
46
+ "pillow>=10.0",
46
47
  ]
47
48
 
48
49
  [tool.hatch.build.targets.wheel]
@@ -0,0 +1,228 @@
1
+ """Model output on its native unstructured mesh, as a time series of per-element values.
2
+
3
+ `MeshFrames` is the model-agnostic contract: node coordinates, triangle connectivity and, for each
4
+ time, one value per triangle (element). `mesh_frames(model, bbox, start, end)` builds one from a
5
+ registered model adapter. `forcingkit.render` turns it into image frames.
6
+
7
+ Adding a model
8
+ --------------
9
+ An adapter is one function ``(bbox, times) -> MeshFrames`` registered under a name::
10
+
11
+ from forcingkit.mesh_frames import MeshFrames, register_adapter
12
+
13
+ @register_adapter("mymodel")
14
+ def _mymodel(bbox, times):
15
+ # bbox: (min_lon, min_lat, max_lon, max_lat); times: tz-aware UTC DatetimeIndex.
16
+ # Read every element that overlaps bbox (a margin beyond it is fine: the renderer
17
+ # clips) for the requested times; omit any time the source does not hold.
18
+ return MeshFrames(times=..., node_lon=..., node_lat=..., tri=..., u=..., v=...)
19
+
20
+ The registry builds the requested time axis (``start`` to ``end`` inclusive, every ``stride_h``
21
+ hours), so an adapter only reads and reshapes. Triangle meshes from FVCOM, ADCIRC or SCHISM fit
22
+ directly: ``tri`` is the zero-based element-to-node table (FVCOM ``nv`` is one-based and
23
+ transposed), and element values are the model's own (FVCOM u, v at centroids) or the mean of the
24
+ three node values for node-centred models.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass, field
31
+ from typing import Callable, Sequence
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ BBox = Sequence[float]
39
+ Adapter = Callable[[tuple[float, float, float, float], pd.DatetimeIndex], "MeshFrames"]
40
+
41
+ _ADAPTERS: dict[str, Adapter] = {}
42
+
43
+
44
+ def _to_utc(t) -> pd.Timestamp:
45
+ ts = pd.Timestamp(t)
46
+ return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC")
47
+
48
+
49
+ @dataclass
50
+ class MeshFrames:
51
+ """Per-element values on a triangle mesh, one frame per time.
52
+
53
+ times: (t,) tz-aware UTC. Naive input is taken as UTC.
54
+ node_lon, node_lat: (n_node,) degrees.
55
+ tri: (n_tri, 3) zero-based node indices, one row per element.
56
+ u, v: (t, n_tri) eastward and northward value per element (m/s for currents). NaN marks an
57
+ element with no value at that time (dry, masked); it renders as outside the mesh.
58
+
59
+ Values are on elements only: one value per triangle, drawn with flat shading.
60
+ """
61
+
62
+ times: pd.DatetimeIndex
63
+ node_lon: np.ndarray
64
+ node_lat: np.ndarray
65
+ tri: np.ndarray
66
+ u: np.ndarray
67
+ v: np.ndarray
68
+ attrs: dict = field(default_factory=dict)
69
+
70
+ def __post_init__(self) -> None:
71
+ times = pd.DatetimeIndex(self.times)
72
+ self.times = (
73
+ times.tz_localize("UTC") if times.tz is None else times.tz_convert("UTC")
74
+ )
75
+ self.node_lon = np.asarray(self.node_lon, dtype=np.float64)
76
+ self.node_lat = np.asarray(self.node_lat, dtype=np.float64)
77
+ self.tri = np.asarray(self.tri, dtype=np.int64)
78
+ self.u = np.asarray(self.u, dtype=np.float32)
79
+ self.v = np.asarray(self.v, dtype=np.float32)
80
+ n_node = self.node_lon.shape[0]
81
+ if self.node_lat.shape != (n_node,) or self.node_lon.ndim != 1:
82
+ raise ValueError("node_lon and node_lat must be 1-D and the same length")
83
+ if self.tri.ndim != 2 or self.tri.shape[1] != 3:
84
+ raise ValueError(f"tri must be (n_tri, 3), got {self.tri.shape}")
85
+ if self.tri.size and (self.tri.min() < 0 or self.tri.max() >= n_node):
86
+ raise ValueError("tri holds node indices outside node_lon/node_lat")
87
+ shape = (len(self.times), self.tri.shape[0])
88
+ if self.u.shape != shape or self.v.shape != shape:
89
+ raise ValueError(
90
+ f"u and v must be (t, n_tri) = {shape}, got {self.u.shape} and {self.v.shape}"
91
+ )
92
+
93
+ def __len__(self) -> int:
94
+ return len(self.times)
95
+
96
+ def speed(self) -> np.ndarray:
97
+ """(t, n_tri) magnitude of (u, v)."""
98
+ return np.hypot(self.u, self.v)
99
+
100
+
101
+ def register_adapter(name: str) -> Callable[[Adapter], Adapter]:
102
+ """Register `fn(bbox, times) -> MeshFrames` under `name` (see the module docstring)."""
103
+
104
+ def deco(fn: Adapter) -> Adapter:
105
+ _ADAPTERS[name.lower()] = fn
106
+ return fn
107
+
108
+ return deco
109
+
110
+
111
+ def available_models() -> list[str]:
112
+ """Names accepted by `mesh_frames`."""
113
+ return sorted(_ADAPTERS)
114
+
115
+
116
+ def frame_times(start, end, stride_h: int = 1) -> pd.DatetimeIndex:
117
+ """UTC times from `start` to `end` inclusive every `stride_h` hours, `start` floored to the hour."""
118
+ if stride_h < 1:
119
+ raise ValueError(f"stride_h must be a positive number of hours, got {stride_h}")
120
+ t0, t1 = _to_utc(start).floor("h"), _to_utc(end)
121
+ if t1 < t0:
122
+ raise ValueError(f"end {t1} precedes start {t0}")
123
+ return pd.date_range(t0, t1, freq=f"{int(stride_h)}h")
124
+
125
+
126
+ def mesh_frames(model: str, bbox: BBox, start, end, stride_h: int = 1) -> MeshFrames:
127
+ """Values of `model` on its native mesh for the elements in `bbox` = (W, S, E, N), from `start`
128
+ to `end` inclusive every `stride_h` hours (UTC; naive times are taken as UTC).
129
+
130
+ Raises KeyError for an unregistered model. Times the source does not hold are left out of the
131
+ result and logged.
132
+ """
133
+ key = model.lower()
134
+ if key not in _ADAPTERS:
135
+ raise KeyError(
136
+ f"No mesh adapter for {model!r}; available: {available_models()}"
137
+ )
138
+ w, s, e, n = (float(x) for x in bbox)
139
+ if not (e > w and n > s):
140
+ raise ValueError(f"bbox must be (W, S, E, N) with E > W and N > S, got {bbox}")
141
+ times = frame_times(start, end, stride_h)
142
+ frames = _ADAPTERS[key]((w, s, e, n), times)
143
+ missing = times.difference(frames.times)
144
+ if len(missing):
145
+ logger.warning(
146
+ f"{model}: {len(missing)} of {len(times)} requested times unavailable"
147
+ )
148
+ return frames
149
+
150
+
151
+ NECOFS_PAD_DEG = 0.05 # wider than the coarsest GOM7 elements near the coast
152
+
153
+
154
+ @register_adapter("necofs")
155
+ def _necofs_surface(
156
+ bbox: tuple[float, float, float, float], times: pd.DatetimeIndex
157
+ ) -> MeshFrames:
158
+ """NECOFS FVCOM GOM7 top-layer currents at element centroids.
159
+
160
+ Each hour is read from the SMAST daily history file that holds it (via `get_necofs_url`, which
161
+ falls back to the rolling forecast); consecutive hours served by the same file are read in one
162
+ request, and the results are concatenated with duplicate times dropped. `read_surface` keeps
163
+ elements whose centroids lie in its box, so the box is widened by NECOFS_PAD_DEG to cover the
164
+ requested one edge to edge.
165
+ """
166
+ from forcingkit.fetchers import necofs
167
+
168
+ w, s, e, n = bbox
169
+ p = NECOFS_PAD_DEG
170
+ read_box = [w - p, s - p, e + p, n + p]
171
+
172
+ naive = times.tz_convert("UTC").tz_localize(None)
173
+ url_by_day: dict[pd.Timestamp, str] = {}
174
+ groups: list[tuple[str, list[pd.Timestamp]]] = []
175
+ for t in naive:
176
+ day = necofs.necofs_archive_file_date(t)
177
+ if day not in url_by_day:
178
+ url_by_day[day] = necofs.get_necofs_url(day - pd.Timedelta(days=1))
179
+ url = url_by_day[day]
180
+ if groups and groups[-1][0] == url:
181
+ groups[-1][1].append(t)
182
+ else:
183
+ groups.append((url, [t]))
184
+
185
+ ref = None
186
+ got_t: list[pd.Timestamp] = []
187
+ got_u: list[np.ndarray] = []
188
+ got_v: list[np.ndarray] = []
189
+ seen: set[pd.Timestamp] = set()
190
+ for url, ts in groups:
191
+ hours = int((ts[-1] - ts[0]) / pd.Timedelta(hours=1)) + 1
192
+ try:
193
+ ds = necofs.read_surface(read_box, ts[0], hours, dap_url=url)
194
+ except RuntimeError as exc:
195
+ logger.warning(f"necofs: skipping {ts[0]} to {ts[-1]}: {exc}")
196
+ continue
197
+ if ref is None:
198
+ ref = ds
199
+ elif not np.array_equal(ds["element_id"].values, ref["element_id"].values):
200
+ raise RuntimeError(f"{url} subsets a different set of elements for {bbox}")
201
+ ds_times = pd.DatetimeIndex(ds["time"].values)
202
+ for t in ts:
203
+ i = ds_times.get_indexer([t])[0]
204
+ if i < 0 or t in seen:
205
+ continue
206
+ seen.add(t)
207
+ got_t.append(t)
208
+ got_u.append(ds["u"].values[i])
209
+ got_v.append(ds["v"].values[i])
210
+ if ref is None or not got_t:
211
+ raise RuntimeError(f"NECOFS holds none of {times[0]} to {times[-1]}")
212
+
213
+ order = np.argsort(np.asarray(got_t, dtype="datetime64[ns]"))
214
+ return MeshFrames(
215
+ times=pd.DatetimeIndex([got_t[i] for i in order]),
216
+ node_lon=ref["node_lon"].values,
217
+ node_lat=ref["node_lat"].values,
218
+ tri=ref["triangles"].values,
219
+ u=np.stack([got_u[i] for i in order]),
220
+ v=np.stack([got_v[i] for i in order]),
221
+ attrs={
222
+ "model": "necofs",
223
+ "source": "NECOFS FVCOM GOM7, UMass Dartmouth SMAST",
224
+ "variable": "surface current (top sigma layer)",
225
+ "units": "m s-1",
226
+ "bbox": list(bbox),
227
+ },
228
+ )
@@ -0,0 +1,70 @@
1
+ # Currents speed ramp: speed_m_s,r,g,b (sRGB). Interpolate in log(speed); speeds below the
2
+ # first row take its colour (floor 0.02 m/s) and speeds above the last row (3.1 m/s) clamp to it.
3
+ # Rows are sampled in CIELAB with lightness falling monotonically, linear in log(speed) between the
4
+ # knots, from L* 93 (pale aqua) through L* 83 (aqua) and L* 50 (teal) to L* 25 (deep blue-green);
5
+ # hue stays between 186 and 212 degrees, after cmocean "tempo" (Thyng et al. 2016): clear of chart
6
+ # land buff and drying green, the violet wind ramp and the blue waves ramp.
7
+ # Knots from 2026 NOAA CO-OPS current predictions (MAX_SLACK), Long Island Sound and Block Island
8
+ # Sound: regional median daily maximum 0.494 m/s; ACT2611 Little Gull Island median daily maximum
9
+ # 2.418 m/s, 95th percentile 2.964 m/s, annual maximum 3.097 m/s.
10
+ speed_m_s,r,g,b
11
+ 0.0200,214,240,244
12
+ 0.0221,212,239,243
13
+ 0.0244,210,238,242
14
+ 0.0270,208,238,242
15
+ 0.0299,206,237,241
16
+ 0.0330,204,236,240
17
+ 0.0365,202,236,240
18
+ 0.0403,201,235,238
19
+ 0.0446,199,235,238
20
+ 0.0493,197,234,237
21
+ 0.0545,194,233,236
22
+ 0.0602,193,233,236
23
+ 0.0666,191,232,234
24
+ 0.0736,189,231,233
25
+ 0.0813,187,231,233
26
+ 0.0899,185,230,232
27
+ 0.0994,183,229,230
28
+ 0.1099,182,229,230
29
+ 0.1215,179,228,229
30
+ 0.1343,177,227,227
31
+ 0.1484,176,227,227
32
+ 0.1641,174,226,226
33
+ 0.1813,172,225,224
34
+ 0.2005,170,225,224
35
+ 0.2216,168,224,222
36
+ 0.2449,166,223,221
37
+ 0.2708,165,223,220
38
+ 0.2993,163,222,219
39
+ 0.3309,160,221,218
40
+ 0.3657,159,221,217
41
+ 0.4043,157,220,215
42
+ 0.4469,155,219,214
43
+ 0.4940,154,219,213
44
+ 0.5348,147,214,208
45
+ 0.5790,141,210,204
46
+ 0.6269,134,206,200
47
+ 0.6787,128,202,195
48
+ 0.7348,121,197,190
49
+ 0.7955,114,193,186
50
+ 0.8613,108,189,182
51
+ 0.9324,101,185,177
52
+ 1.0095,94,180,172
53
+ 1.0929,87,176,168
54
+ 1.1833,80,172,164
55
+ 1.2810,72,168,159
56
+ 1.3869,64,164,155
57
+ 1.5015,56,159,150
58
+ 1.6256,47,155,146
59
+ 1.7600,37,151,141
60
+ 1.9054,23,147,137
61
+ 2.0629,0,143,133
62
+ 2.2334,11,138,128
63
+ 2.4180,8,134,124
64
+ 2.5198,1,122,116
65
+ 2.6259,5,110,107
66
+ 2.7365,1,99,98
67
+ 2.8518,2,88,89
68
+ 2.9719,2,77,80
69
+ 3.0970,2,67,71
70
+ 3.1000,2,67,71
@@ -0,0 +1,69 @@
1
+ # Currents speed ramp, dark-basemap twin: speed_m_s,r,g,b (sRGB). Interpolate in log(speed); speeds below the
2
+ # first row take its colour (floor 0.02 m/s) and speeds above the last row (3.1 m/s) clamp to it.
3
+ # Rows are sampled in CIELAB with lightness rising monotonically, linear in log(speed) between the knots,
4
+ # from L* 20 (deep blue-teal) through L* 40 (dark teal) and L* 76 (seafoam) to L* 90 (pale seafoam), for a
5
+ # dark basemap; hue 215 to 185 degrees, mirroring currents_lis.csv on the same speeds, so the two swap by theme.
6
+ # Knots from 2026 NOAA CO-OPS current predictions (MAX_SLACK), Long Island Sound and Block Island
7
+ # Sound: regional median daily maximum 0.494 m/s; ACT2611 Little Gull Island median daily maximum
8
+ # 2.418 m/s, 95th percentile 2.964 m/s, annual maximum 3.097 m/s.
9
+ speed_m_s,r,g,b
10
+ 0.0200,1,54,61
11
+ 0.0221,1,56,63
12
+ 0.0244,2,57,64
13
+ 0.0270,1,59,66
14
+ 0.0299,0,60,67
15
+ 0.0330,2,62,69
16
+ 0.0365,1,63,70
17
+ 0.0403,0,65,72
18
+ 0.0446,1,66,73
19
+ 0.0493,1,68,75
20
+ 0.0545,0,69,76
21
+ 0.0602,1,71,78
22
+ 0.0666,0,73,80
23
+ 0.0736,3,74,80
24
+ 0.0813,2,76,82
25
+ 0.0899,1,77,83
26
+ 0.0994,3,79,85
27
+ 0.1099,1,81,87
28
+ 0.1215,3,82,88
29
+ 0.1343,1,84,90
30
+ 0.1484,1,85,90
31
+ 0.1641,3,87,92
32
+ 0.1813,1,89,94
33
+ 0.2005,3,90,95
34
+ 0.2216,1,92,97
35
+ 0.2449,5,93,97
36
+ 0.2708,2,95,99
37
+ 0.2993,5,97,101
38
+ 0.3309,2,99,103
39
+ 0.3657,1,100,103
40
+ 0.4043,4,102,105
41
+ 0.4469,0,104,107
42
+ 0.4940,3,105,108
43
+ 0.5348,1,110,112
44
+ 0.5790,3,115,117
45
+ 0.6269,7,120,121
46
+ 0.6787,12,125,125
47
+ 0.7348,15,130,130
48
+ 0.7955,19,135,134
49
+ 0.8613,24,140,138
50
+ 0.9324,26,145,143
51
+ 1.0095,30,150,147
52
+ 1.0929,34,155,151
53
+ 1.1833,38,160,155
54
+ 1.2810,41,165,159
55
+ 1.3869,45,170,163
56
+ 1.5015,48,176,168
57
+ 1.6256,52,181,172
58
+ 1.7600,56,186,176
59
+ 1.9054,59,191,180
60
+ 2.0629,63,197,185
61
+ 2.2334,67,202,189
62
+ 2.4180,71,207,192
63
+ 2.5198,90,213,199
64
+ 2.6259,107,218,204
65
+ 2.7365,122,224,211
66
+ 2.8518,137,230,218
67
+ 2.9719,150,235,223
68
+ 3.0970,164,241,230
69
+ 3.1000,164,241,230