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.
- {forcingkit-0.8.0 → forcingkit-0.9.0}/PKG-INFO +2 -1
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/conf.py +1 -1
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/index.rst +1 -0
- forcingkit-0.9.0/docs/source/mesh_frames.rst +89 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/pyproject.toml +2 -1
- forcingkit-0.9.0/src/forcingkit/mesh_frames.py +228 -0
- forcingkit-0.9.0/src/forcingkit/ramps/currents_lis.csv +70 -0
- forcingkit-0.9.0/src/forcingkit/ramps/currents_lis_dark.csv +69 -0
- forcingkit-0.9.0/src/forcingkit/render.py +487 -0
- forcingkit-0.9.0/tests/integration/test_mesh_frames_necofs.py +32 -0
- forcingkit-0.9.0/tests/unit/test_mesh_frames.py +383 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/uv.lock +3 -1
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.agents/forcingkit.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.claude/CLAUDE.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.env.template +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/copilot-instructions.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/docs.yml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/publish.yml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.github/workflows/tests.yml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.gitignore +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.markdownlint.yaml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.pre-commit-config.yaml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/.python-version +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/AGENTS.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/CONTRIBUTING.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/Dockerfile +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/LICENSE +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/README.md +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docker-compose.yml +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/Makefile +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/make.bat +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/requirements-docs.txt +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_extra/CNAME +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_static/custom.css +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/atmospheric_forcing.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/fetchers.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/hycom.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/necofs.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/nyofs.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/rivers.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/roadmap.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/docs/source/sources.rst +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/main.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/__init__.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/__init__.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/main.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/removed.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/forcingkit_serve/routers/viewer.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/service/run_server.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/__init__.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/dispatcher.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/coastwatch.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/dbofs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/erddap.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hrrr_points.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hycom.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/hydrography.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/ndbc.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/necofs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/noaa.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/nyofs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/usgs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/settings.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/src/forcingkit/zarr_stream.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/app.js +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/favicon.ico +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/favicon.svg +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/index.html +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/logo.svg +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/preview3d.js +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/static/styles.css +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/__init__.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_auth.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_erddap_fetch.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_obc_offshore_nj.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_ofs_archive_paths.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_dem.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_grib.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/scripts/inspect_zarr.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_coastwatch.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_dbofs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_dispatcher.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_erddap.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_atm.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_idx.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hrrr_points.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_hycom.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_main.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_ndbc.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_necofs_parent.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_noaa_currents.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_noaa_datum.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_nyofs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_obc_donor_report.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_obc_pipeline.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_removed_routes.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_settings.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_usgs.py +0 -0
- {forcingkit-0.8.0 → forcingkit-0.9.0}/tests/unit/test_usgs_rivers.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
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
|