forcingkit 0.8.0__tar.gz → 0.10.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 (111) hide show
  1. {forcingkit-0.8.0 → forcingkit-0.10.0}/PKG-INFO +2 -1
  2. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/conf.py +1 -1
  3. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/index.rst +1 -0
  4. forcingkit-0.10.0/docs/source/mesh_frames.rst +95 -0
  5. {forcingkit-0.8.0 → forcingkit-0.10.0}/pyproject.toml +2 -1
  6. forcingkit-0.10.0/src/forcingkit/mesh_frames.py +228 -0
  7. forcingkit-0.10.0/src/forcingkit/ramps/currents_lis.csv +70 -0
  8. forcingkit-0.10.0/src/forcingkit/ramps/currents_lis_dark.csv +69 -0
  9. forcingkit-0.10.0/src/forcingkit/ramps/currents_lis_deep.csv +71 -0
  10. forcingkit-0.10.0/src/forcingkit/ramps/currents_lis_haline.csv +70 -0
  11. forcingkit-0.10.0/src/forcingkit/render.py +491 -0
  12. forcingkit-0.10.0/tests/integration/test_mesh_frames_necofs.py +32 -0
  13. forcingkit-0.10.0/tests/unit/test_mesh_frames.py +405 -0
  14. {forcingkit-0.8.0 → forcingkit-0.10.0}/uv.lock +3 -1
  15. {forcingkit-0.8.0 → forcingkit-0.10.0}/.agents/forcingkit.md +0 -0
  16. {forcingkit-0.8.0 → forcingkit-0.10.0}/.claude/CLAUDE.md +0 -0
  17. {forcingkit-0.8.0 → forcingkit-0.10.0}/.env.template +0 -0
  18. {forcingkit-0.8.0 → forcingkit-0.10.0}/.github/copilot-instructions.md +0 -0
  19. {forcingkit-0.8.0 → forcingkit-0.10.0}/.github/workflows/docs.yml +0 -0
  20. {forcingkit-0.8.0 → forcingkit-0.10.0}/.github/workflows/publish.yml +0 -0
  21. {forcingkit-0.8.0 → forcingkit-0.10.0}/.github/workflows/tests.yml +0 -0
  22. {forcingkit-0.8.0 → forcingkit-0.10.0}/.gitignore +0 -0
  23. {forcingkit-0.8.0 → forcingkit-0.10.0}/.markdownlint.yaml +0 -0
  24. {forcingkit-0.8.0 → forcingkit-0.10.0}/.pre-commit-config.yaml +0 -0
  25. {forcingkit-0.8.0 → forcingkit-0.10.0}/.python-version +0 -0
  26. {forcingkit-0.8.0 → forcingkit-0.10.0}/AGENTS.md +0 -0
  27. {forcingkit-0.8.0 → forcingkit-0.10.0}/CONTRIBUTING.md +0 -0
  28. {forcingkit-0.8.0 → forcingkit-0.10.0}/Dockerfile +0 -0
  29. {forcingkit-0.8.0 → forcingkit-0.10.0}/LICENSE +0 -0
  30. {forcingkit-0.8.0 → forcingkit-0.10.0}/README.md +0 -0
  31. {forcingkit-0.8.0 → forcingkit-0.10.0}/docker-compose.yml +0 -0
  32. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/Makefile +0 -0
  33. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/make.bat +0 -0
  34. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/requirements-docs.txt +0 -0
  35. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/_extra/CNAME +0 -0
  36. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/_static/custom.css +0 -0
  37. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  38. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/atmospheric_forcing.rst +0 -0
  39. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/fetchers.rst +0 -0
  40. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/hycom.rst +0 -0
  41. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/necofs.rst +0 -0
  42. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/nyofs.rst +0 -0
  43. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/rivers.rst +0 -0
  44. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/roadmap.rst +0 -0
  45. {forcingkit-0.8.0 → forcingkit-0.10.0}/docs/source/sources.rst +0 -0
  46. {forcingkit-0.8.0 → forcingkit-0.10.0}/main.py +0 -0
  47. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/__init__.py +0 -0
  48. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/__init__.py +0 -0
  49. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/main.py +0 -0
  50. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  51. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  52. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/routers/removed.py +0 -0
  53. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/forcingkit_serve/routers/viewer.py +0 -0
  54. {forcingkit-0.8.0 → forcingkit-0.10.0}/service/run_server.py +0 -0
  55. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/__init__.py +0 -0
  56. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
  57. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/dispatcher.py +0 -0
  58. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/coastwatch.py +0 -0
  59. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  60. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/erddap.py +0 -0
  61. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  62. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  63. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/hrrr_points.py +0 -0
  64. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/hycom.py +0 -0
  65. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  66. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  67. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/necofs.py +0 -0
  68. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/noaa.py +0 -0
  69. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  70. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/usgs.py +0 -0
  71. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
  72. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/settings.py +0 -0
  73. {forcingkit-0.8.0 → forcingkit-0.10.0}/src/forcingkit/zarr_stream.py +0 -0
  74. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/app.js +0 -0
  75. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/favicon.ico +0 -0
  76. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/favicon.svg +0 -0
  77. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/index.html +0 -0
  78. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/logo.svg +0 -0
  79. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/preview3d.js +0 -0
  80. {forcingkit-0.8.0 → forcingkit-0.10.0}/static/styles.css +0 -0
  81. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/__init__.py +0 -0
  82. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_auth.py +0 -0
  83. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_erddap_fetch.py +0 -0
  84. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  85. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_obc_offshore_nj.py +0 -0
  86. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_ofs_archive_paths.py +0 -0
  87. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  88. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/scripts/inspect_dem.py +0 -0
  89. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/scripts/inspect_grib.py +0 -0
  90. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/scripts/inspect_zarr.py +0 -0
  91. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_coastwatch.py +0 -0
  92. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_dbofs.py +0 -0
  93. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_dispatcher.py +0 -0
  94. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_erddap.py +0 -0
  95. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_hrrr_atm.py +0 -0
  96. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_hrrr_idx.py +0 -0
  97. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_hrrr_points.py +0 -0
  98. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_hycom.py +0 -0
  99. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_main.py +0 -0
  100. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_ndbc.py +0 -0
  101. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_necofs_parent.py +0 -0
  102. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_noaa_currents.py +0 -0
  103. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_noaa_datum.py +0 -0
  104. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_nyofs.py +0 -0
  105. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_obc_donor_report.py +0 -0
  106. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_obc_pipeline.py +0 -0
  107. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_removed_routes.py +0 -0
  108. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_settings.py +0 -0
  109. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_usgs.py +0 -0
  110. {forcingkit-0.8.0 → forcingkit-0.10.0}/tests/unit/test_usgs_rivers.py +0 -0
  111. {forcingkit-0.8.0 → forcingkit-0.10.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.10.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.10.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,95 @@
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 aqua through
49
+ teal to deep blue-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
+ deep blue-teal through teal to pale seafoam with lightness rising monotonically, so the two
53
+ swap by theme alone;
54
+ - ``packaged_color("currents_lis_deep")`` and ``packaged_color("currents_lis_haline")``: multi-hue
55
+ alternatives on the same speeds, after cmocean "deep" (light backgrounds: pale green through
56
+ teal and navy to deep purple) and "haline" (dark backgrounds: indigo through blue and green to
57
+ pale yellow). They separate speeds more strongly than the single-hue pair, but they are not
58
+ mirror images: purple is fast in one and slow in the other. Use them for a field shown alone
59
+ with its legend, not beside other colour-mapped layers.
60
+
61
+ Python
62
+ ------
63
+
64
+ .. code-block:: python
65
+
66
+ from forcingkit.mesh_frames import mesh_frames
67
+ from forcingkit.render import lut_color, render_frames, write_frames
68
+
69
+ bbox = (-73.0, 40.95, -72.6, 41.15) # central Long Island Sound
70
+ frames = mesh_frames("necofs", bbox, "2026-10-15T22:00Z", "2026-10-16T02:00Z", stride_h=1)
71
+ color = lut_color([(0.05, 230, 240, 250), (0.5, 120, 160, 200), (2.0, 20, 40, 90)], log=True)
72
+ images = render_frames(
73
+ frames, bbox, (1200, 800), color,
74
+ edges=((0, 0, 0, 40), 1), background=(242, 243, 240, 255),
75
+ )
76
+ write_frames(images, "frames/", times=frames.times)
77
+
78
+ NECOFS hours are read from the SMAST daily history file that holds each one (falling back to the
79
+ rolling forecast), so a window may cross several archive days.
80
+
81
+ Command line
82
+ ------------
83
+
84
+ .. code-block:: bash
85
+
86
+ uv run python -m forcingkit.render --model necofs \
87
+ --bbox -73.0 40.95 -72.6 41.15 \
88
+ --start 2026-10-15T22:00Z --end 2026-10-16T02:00Z --stride-h 1 \
89
+ --size 1200x800 --ramp viridis --vmax 1.5 \
90
+ --edges 0,0,0,40 --background 242,243,240 --out frames/
91
+
92
+ ``--ramp`` takes a built-in name, a packaged table (``currents_lis``, ``currents_lis_dark``, ``currents_lis_deep``, ``currents_lis_haline``) or a path to a
93
+ ``value,r,g,b[,a]`` CSV (with ``--log`` and ``--floor``). The output directory receives
94
+ ``frame_0000.png``, ``frame_0001.png``, ... and ``frames.json``, which lists the frame times, the box
95
+ 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.10.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
@@ -0,0 +1,71 @@
1
+ # Currents speed ramp after cmocean "deep" (Thyng et al. 2016; cmocean is MIT licensed):
2
+ # speed_m_s,r,g,b (sRGB), on the same speeds as currents_lis. Interpolate in log(speed); speeds below the
3
+ # first row take its colour (floor 0.02 m/s) and speeds above the last row (3.1 m/s) clamp to it.
4
+ # Multi-hue for light backgrounds, for a field shown alone with its legend (exports, animations): pale
5
+ # green at slack through teal and navy to deep purple, lightness falling with speed. Colormap position
6
+ # is piecewise linear in log(speed): 0.02 -> 0.12 (past deep's pale-yellow start, so slack never reads
7
+ # as sand), 0.494 -> 0.38, 2.418 -> 0.80, 3.097 -> 1.0.
8
+ # Knots from 2026 NOAA CO-OPS current predictions (MAX_SLACK), Long Island Sound and Block Island
9
+ # Sound: regional median daily maximum 0.494 m/s; ACT2611 Little Gull Island median daily maximum
10
+ # 2.418 m/s, 95th percentile 2.964 m/s, annual maximum 3.097 m/s.
11
+ speed_m_s,r,g,b
12
+ 0.0200,191,231,174
13
+ 0.0221,187,230,172
14
+ 0.0244,183,229,171
15
+ 0.0270,178,227,170
16
+ 0.0299,172,225,168
17
+ 0.0330,167,224,167
18
+ 0.0365,163,222,166
19
+ 0.0403,159,221,165
20
+ 0.0446,154,219,165
21
+ 0.0493,150,218,164
22
+ 0.0545,146,216,164
23
+ 0.0602,141,215,163
24
+ 0.0666,137,213,163
25
+ 0.0736,133,211,163
26
+ 0.0813,129,210,163
27
+ 0.0899,125,208,163
28
+ 0.0994,120,206,163
29
+ 0.1099,117,204,163
30
+ 0.1215,113,202,163
31
+ 0.1343,110,200,163
32
+ 0.1484,107,198,163
33
+ 0.1641,105,196,164
34
+ 0.1813,102,194,164
35
+ 0.2005,100,193,164
36
+ 0.2216,98,191,164
37
+ 0.2449,96,189,164
38
+ 0.2708,94,187,164
39
+ 0.2993,92,185,164
40
+ 0.3309,90,183,164
41
+ 0.3657,88,179,164
42
+ 0.4043,87,177,164
43
+ 0.4469,86,175,164
44
+ 0.4940,85,173,163
45
+ 0.5348,82,168,163
46
+ 0.5790,79,162,162
47
+ 0.6269,77,157,161
48
+ 0.6787,75,152,160
49
+ 0.7348,73,146,159
50
+ 0.7955,72,141,157
51
+ 0.8613,70,136,156
52
+ 0.9324,68,130,155
53
+ 1.0095,67,125,154
54
+ 1.0929,65,119,153
55
+ 1.1833,64,114,152
56
+ 1.2810,63,109,151
57
+ 1.3869,62,103,149
58
+ 1.5015,62,98,148
59
+ 1.6256,62,92,147
60
+ 1.7600,62,86,145
61
+ 1.9054,63,80,142
62
+ 2.0629,65,74,137
63
+ 2.2334,65,69,131
64
+ 2.4180,65,64,123
65
+ 2.5198,63,57,108
66
+ 2.6259,60,51,95
67
+ 2.7365,56,45,81
68
+ 2.8518,51,39,68
69
+ 2.9719,45,32,55
70
+ 3.0970,40,26,44
71
+ 3.1000,40,26,44
@@ -0,0 +1,70 @@
1
+ # Currents speed ramp after cmocean "haline" (Thyng et al. 2016; cmocean is MIT licensed):
2
+ # speed_m_s,r,g,b (sRGB), on the same speeds as currents_lis. Interpolate in log(speed); speeds below the
3
+ # first row take its colour (floor 0.02 m/s) and speeds above the last row (3.1 m/s) clamp to it.
4
+ # Multi-hue for dark backgrounds, for a field shown alone with its legend (exports, animations): indigo
5
+ # at slack through blue and green to pale yellow, lightness rising with speed. Colormap position is
6
+ # piecewise linear in log(speed): 0.02 -> 0, 0.494 -> 0.29, 2.418 -> 0.80, 3.097 -> 1.0.
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,42,24,108
12
+ 0.0221,42,25,113
13
+ 0.0244,43,25,117
14
+ 0.0270,44,26,122
15
+ 0.0299,45,27,130
16
+ 0.0330,46,27,135
17
+ 0.0365,46,28,140
18
+ 0.0403,46,29,147
19
+ 0.0446,46,30,152
20
+ 0.0493,45,32,156
21
+ 0.0545,43,36,160
22
+ 0.0602,41,39,162
23
+ 0.0666,38,43,163
24
+ 0.0736,34,48,163
25
+ 0.0813,32,52,162
26
+ 0.0899,29,55,161
27
+ 0.0994,25,60,159
28
+ 0.1099,23,63,158
29
+ 0.1215,21,65,156
30
+ 0.1343,18,69,155
31
+ 0.1484,16,72,153
32
+ 0.1641,15,74,152
33
+ 0.1813,13,78,150
34
+ 0.2005,12,80,149
35
+ 0.2216,12,82,148
36
+ 0.2449,12,84,147
37
+ 0.2708,13,87,146
38
+ 0.2993,14,89,145
39
+ 0.3309,15,91,144
40
+ 0.3657,17,94,143
41
+ 0.4043,19,96,142
42
+ 0.4469,20,98,142
43
+ 0.4940,23,100,141
44
+ 0.5348,28,106,140
45
+ 0.5790,34,112,138
46
+ 0.6269,38,117,138
47
+ 0.6787,43,123,137
48
+ 0.7348,47,128,137
49
+ 0.7955,52,134,136
50
+ 0.8613,55,139,136
51
+ 0.9324,59,145,135
52
+ 1.0095,62,151,135
53
+ 1.0929,66,157,133
54
+ 1.1833,70,164,131
55
+ 1.2810,74,169,129
56
+ 1.3869,79,175,126
57
+ 1.5015,84,181,123
58
+ 1.6256,92,187,119
59
+ 1.7600,99,192,114
60
+ 1.9054,110,198,109
61
+ 2.0629,120,203,104
62
+ 2.2334,134,208,98
63
+ 2.4180,148,211,93
64
+ 2.5198,170,216,92
65
+ 2.6259,189,220,98
66
+ 2.7365,208,224,109
67
+ 2.8518,224,229,122
68
+ 2.9719,240,234,138
69
+ 3.0970,253,239,154
70
+ 3.1000,253,239,154