forcingkit 0.10.0__tar.gz → 0.11.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.10.0 → forcingkit-0.11.0}/PKG-INFO +1 -1
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/conf.py +1 -1
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/fetchers.rst +9 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/index.rst +6 -0
- forcingkit-0.11.0/docs/source/layered_mesh.rst +110 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/necofs.rst +85 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/pyproject.toml +1 -1
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/main.py +57 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/dispatcher.py +76 -0
- forcingkit-0.11.0/src/forcingkit/fetchers/hrrr_levels.py +265 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/necofs.py +339 -0
- forcingkit-0.11.0/src/forcingkit/layered_mesh.py +865 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/mesh_frames.py +22 -0
- forcingkit-0.11.0/tests/integration/test_necofs_volume_live.py +37 -0
- forcingkit-0.11.0/tests/unit/test_hrrr_levels.py +149 -0
- forcingkit-0.11.0/tests/unit/test_layered_mesh.py +434 -0
- forcingkit-0.11.0/tests/unit/test_necofs_volume.py +312 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/uv.lock +1 -1
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.agents/forcingkit.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.claude/CLAUDE.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.env.template +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.github/copilot-instructions.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.github/workflows/docs.yml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.github/workflows/publish.yml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.github/workflows/tests.yml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.gitignore +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.markdownlint.yaml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.pre-commit-config.yaml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/.python-version +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/AGENTS.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/CONTRIBUTING.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/Dockerfile +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/LICENSE +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/README.md +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docker-compose.yml +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/Makefile +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/make.bat +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/requirements-docs.txt +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/_extra/CNAME +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/_static/custom.css +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/atmospheric_forcing.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/hycom.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/mesh_frames.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/nyofs.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/rivers.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/roadmap.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/docs/source/sources.rst +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/main.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/__init__.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/__init__.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/removed.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/viewer.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/service/run_server.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/__init__.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/coastwatch.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/dbofs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/erddap.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr_points.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hycom.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hydrography.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/ndbc.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/noaa.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/nyofs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/usgs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis.csv +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis_dark.csv +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis_deep.csv +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis_haline.csv +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/render.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/settings.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/src/forcingkit/zarr_stream.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/app.js +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/favicon.ico +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/favicon.svg +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/index.html +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/logo.svg +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/preview3d.js +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/static/styles.css +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/__init__.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_auth.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_erddap_fetch.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_mesh_frames_necofs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_obc_offshore_nj.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_ofs_archive_paths.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/scripts/inspect_dem.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/scripts/inspect_grib.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/scripts/inspect_zarr.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_coastwatch.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_dbofs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_dispatcher.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_erddap.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_atm.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_idx.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_points.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_hycom.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_main.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_mesh_frames.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_ndbc.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_necofs_parent.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_noaa_currents.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_noaa_datum.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_nyofs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_obc_donor_report.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_obc_pipeline.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_removed_routes.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_settings.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_usgs.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.0}/tests/unit/test_usgs_rivers.py +0 -0
- {forcingkit-0.10.0 → forcingkit-0.11.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.11.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
|
|
@@ -25,6 +25,15 @@ Atmospheric forcing (``/api/v1/atmosphere``)
|
|
|
25
25
|
<https://cds.climate.copernicus.eu/>`__ through `NumericalEarth
|
|
26
26
|
<https://github.com/NumericalEarth/NumericalEarth.jl>`__ in the model, outside this
|
|
27
27
|
service. See :doc:`atmospheric_forcing`.
|
|
28
|
+
* - **HRRR levels**
|
|
29
|
+
- ``/api/v1/atmosphere/levels`` reads the lowest hybrid levels of the same cycles' native-level
|
|
30
|
+
file (``wrfnatf01``) by byte range: U and V (rotated to east and north), vertical velocity
|
|
31
|
+
(converted from omega with the hydrostatic relation) and temperature every hour, and the
|
|
32
|
+
levels' heights and pressures once, from the first hour, since they move only a few metres
|
|
33
|
+
with the surface pressure. Over open water level 1 is about 10 m above the surface (its
|
|
34
|
+
wind agrees with the surface delivery's 10 m wind), level 6 about 430 m and level 14 about
|
|
35
|
+
2.8 km. Each CONUS message is fetched whole (1 to 2.4 MB), so 14 levels read about 63 MB an
|
|
36
|
+
hour: a 48 h window took 3 minutes and made a 7 MB store (schema ``hrrr-levels-v1``).
|
|
28
37
|
|
|
29
38
|
Parent ocean (``/api/v1/obc``)
|
|
30
39
|
------------------------------
|
|
@@ -63,6 +63,11 @@ What it serves
|
|
|
63
63
|
- HRRR surface fields (10 m wind, 2 m temperature and humidity, pressure, radiation,
|
|
64
64
|
precipitation) on a regular 0.03 degree grid, hourly, as schema ``hrrr-atm-v1``. See
|
|
65
65
|
:doc:`atmospheric_forcing`.
|
|
66
|
+
* - ``POST /api/v1/atmosphere/levels``
|
|
67
|
+
- The air above: HRRR wind, vertical velocity and temperature on its lowest native levels
|
|
68
|
+
(14 by default, about 10 m to 2.8 km above open water), over the same window and grid as
|
|
69
|
+
``/api/v1/atmosphere``, as schema ``hrrr-levels-v1``. For vertical sections and air-parcel
|
|
70
|
+
tracks in a viewer; not a forcing. See :doc:`fetchers`.
|
|
66
71
|
* - ``POST /api/v1/rivers``
|
|
67
72
|
- Freshwater discharge at each listed river mouth in the box, hourly, from the tide-free
|
|
68
73
|
`USGS <https://waterdata.usgs.gov/>`__ stream gauges upstream (chosen per request) scaled
|
|
@@ -167,5 +172,6 @@ PyPI as ``forcingkit``.
|
|
|
167
172
|
:caption: Reference
|
|
168
173
|
|
|
169
174
|
mesh_frames
|
|
175
|
+
layered_mesh
|
|
170
176
|
sources
|
|
171
177
|
roadmap
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Layered mesh: 3D model output on its own mesh
|
|
2
|
+
==============================================
|
|
3
|
+
|
|
4
|
+
``forcingkit.layered_mesh`` holds every layer of a 3D model on the model's own horizontal mesh,
|
|
5
|
+
with no regridding: the triangles, a vertical coordinate, and any number of named fields. It is
|
|
6
|
+
the 3D companion of :doc:`mesh_frames` (``MeshFrames`` stays the single-layer surface-current
|
|
7
|
+
contract; ``LayeredMesh.surface()`` returns one). A viewer can draw a model's own cells from it,
|
|
8
|
+
cut sections through the layers, and colour several models on one shared scale.
|
|
9
|
+
|
|
10
|
+
Data contract
|
|
11
|
+
-------------
|
|
12
|
+
|
|
13
|
+
``LayeredMesh`` holds:
|
|
14
|
+
|
|
15
|
+
- ``times`` (UTC; naive input is taken as UTC);
|
|
16
|
+
- ``node_lon``, ``node_lat`` (degrees) and ``tri`` (``n_elem`` x 3, zero-based node indices);
|
|
17
|
+
- one vertical coordinate:
|
|
18
|
+
|
|
19
|
+
- sigma (terrain-following): ``h`` (``n_node``, depth below the datum, positive down),
|
|
20
|
+
``siglay`` (``[n_layer, n_node]``, sigma at layer centres, surface first: 0 at the surface,
|
|
21
|
+
-1 at the bed), optional ``siglev`` (``[n_layer + 1, n_node]``, at the interfaces) and
|
|
22
|
+
``zeta`` (``[t, n_node]``, surface elevation). A layer centre is at height
|
|
23
|
+
``siglay * (h + zeta) + zeta``;
|
|
24
|
+
- fixed heights: ``layer_z`` (``[n_layer, n_elem]``, height of each layer centre in metres,
|
|
25
|
+
positive up) and an optional ``bed_z`` (``n_elem``);
|
|
26
|
+
- neither, for a single layer at the surface;
|
|
27
|
+
|
|
28
|
+
- ``fields``: ``name -> [t, n_layer, n_elem]`` or ``[t, n_layer, n_node]``, NaN where a cell has
|
|
29
|
+
no value (dry, below the bed, masked);
|
|
30
|
+
- ``meta``: a ``FieldMeta`` per field, whose ``kind`` (``"element"`` or ``"node"``) says which;
|
|
31
|
+
- ``source_index`` (optional, ``n_elem``): each element's index in the source's own numbering
|
|
32
|
+
(an FVCOM element id, or a flat grid-cell index).
|
|
33
|
+
|
|
34
|
+
``element_values(name)`` gives any field per element: as held for an element field, and as the
|
|
35
|
+
mean of the element's three nodes for a node field, which is the flat value a viewer draws for
|
|
36
|
+
that cell. ``speed`` is derived from ``u`` and ``v`` when the mesh holds them.
|
|
37
|
+
|
|
38
|
+
Field metadata
|
|
39
|
+
--------------
|
|
40
|
+
|
|
41
|
+
``FieldMeta`` carries ``name, label, units, lo, hi, log, ticks``, the entries a viewer manifest
|
|
42
|
+
lists for each field (``manifest_entry()`` returns exactly those), plus ``kind`` and
|
|
43
|
+
``symmetric`` (a signed field whose scale is centred on zero). ``encode(values)`` maps values to
|
|
44
|
+
one byte on the scale (1 at or below ``lo``, 255 at or above ``hi``, linear or logarithmic, 0 for
|
|
45
|
+
NaN) and ``decode(codes)`` maps them back, so per-field u8 codes and their legend come from one
|
|
46
|
+
place. Without explicit ``ticks``, legends use round values (1, 2 or 5 times a power of ten).
|
|
47
|
+
|
|
48
|
+
Sections
|
|
49
|
+
--------
|
|
50
|
+
|
|
51
|
+
``section(points, n=200, fields=None, node_values="element")`` samples the mesh at ``n`` points
|
|
52
|
+
spaced evenly by distance along a polyline ``[(lon, lat), ...]`` and returns a ``Section`` with
|
|
53
|
+
``distance`` (m), ``lon``, ``lat``, the host ``element`` (-1 outside the mesh), ``z`` (layer
|
|
54
|
+
centres, ``[t, n_layer, n]``), ``z_interfaces`` (sigma meshes with ``siglev``), ``bed_z`` and
|
|
55
|
+
``values[name]`` (``[t, n_layer, n]``). Element fields take the host element's value; node fields
|
|
56
|
+
take the element mean by default or, with ``node_values="linear"``, are interpolated within the
|
|
57
|
+
element. Heights on a sigma mesh are interpolated within the element and include ``zeta``. Points
|
|
58
|
+
outside the mesh are NaN. Point location uses matplotlib's trapezoid map, and falls back to a
|
|
59
|
+
search over every element for a triangulation it cannot index.
|
|
60
|
+
|
|
61
|
+
Shared colour scales
|
|
62
|
+
--------------------
|
|
63
|
+
|
|
64
|
+
``shared_scales(meshes, names=None, window=None)`` sets ``lo`` and ``hi`` for each field from
|
|
65
|
+
every element value of every mesh that holds it, at the times inside ``window``, weighted by
|
|
66
|
+
element area: the 1st to 99th percentiles, or for a symmetric field ``-hi`` to ``hi`` with ``hi``
|
|
67
|
+
the 99.5th percentile of the magnitude. Weighting by area matters on unstructured meshes, whose
|
|
68
|
+
rivers and harbours hold many small elements; counted per element, those elements set the scale
|
|
69
|
+
to their own extremes (on NECOFS around Long Island Sound on 2 April 2026, temperature reached
|
|
70
|
+
19 degC and salinity 5 PSU that way, against 1.7 to 8.0 degC and 28.5 to 34.2 PSU by area).
|
|
71
|
+
``shared_scale`` does one field and accepts other percentiles.
|
|
72
|
+
|
|
73
|
+
Particle tracks
|
|
74
|
+
---------------
|
|
75
|
+
|
|
76
|
+
``Tracks`` holds particle positions ``xyz`` (``[t, n_particle, 3]``: longitude, latitude, height
|
|
77
|
+
in metres) and an ``alive`` mask (``[t, n_particle]``; a position that is not finite is never
|
|
78
|
+
alive). ``local_xyz(lon0, lat0)`` gives metres about an origin, and ``quantize(lon0, lat0)``
|
|
79
|
+
stores them as int16 per axis with an offset and scale (error at most half a step, about 0.3 m
|
|
80
|
+
across 20 km; samples that are not alive get code 0). Tracking itself is the caller's: any
|
|
81
|
+
tracker that reports positions fits.
|
|
82
|
+
|
|
83
|
+
Adapters
|
|
84
|
+
--------
|
|
85
|
+
|
|
86
|
+
``necofs(bbox, start, end)`` reads NECOFS FVCOM GOM7 through
|
|
87
|
+
``forcingkit.fetchers.necofs.volume`` (see :doc:`necofs`) for the elements whose centres lie in
|
|
88
|
+
the box, hourly: 45 sigma layers with ``zeta``, element ``u``, ``v`` and ``w`` (FVCOM ``ww``,
|
|
89
|
+
m/s, up positive) and node ``T`` (degC) and ``S`` (PSU). ``from_necofs_volume(ds)`` converts a
|
|
90
|
+
dataset already read.
|
|
91
|
+
|
|
92
|
+
``structured(lon, lat, mask, z, times=..., fields=...)`` turns a regular grid (1-D axes) or a
|
|
93
|
+
curvilinear one (2-D centres) into two triangles per cell, both carrying the cell's own value, so
|
|
94
|
+
cells are drawn flat, as the model holds them. Cell corners lie midway between centres (for a
|
|
95
|
+
curvilinear grid, the mean of the four centres around each corner), and the outer corners half a
|
|
96
|
+
cell beyond the outermost centres. ``mask`` keeps water cells; ``z`` is None for one surface
|
|
97
|
+
layer, fixed heights ``[n_layer]`` (z-level models), or ``[n_layer, ny, nx]``; ``fields`` are
|
|
98
|
+
``[t, n_layer, ny, nx]`` or ``[t, ny, nx]``. This is the basis for the NYOFS and DBOFS
|
|
99
|
+
(curvilinear), HYCOM (regular, z levels) and HRRR (regular, surface only for now) adapters.
|
|
100
|
+
|
|
101
|
+
Agreement with an independent pipeline
|
|
102
|
+
--------------------------------------
|
|
103
|
+
|
|
104
|
+
Built from the same NECOFS volumes (2 April 2026, 02:00 to 21:00 UTC, four boxes around Long
|
|
105
|
+
Island Sound), sections along four lines reproduce byte for byte the per-field codes of a
|
|
106
|
+
separate pipeline written for the same data (speed, temperature, salinity and vertical velocity,
|
|
107
|
+
45 layers, every hourly frame: about 11.4 million codes), and its layer interfaces and bed to
|
|
108
|
+
within 0.1 mm. Its area-weighted shared scales agree to 0.02 percent (temperature 1.7408 to
|
|
109
|
+
7.9941 degC against 1.7406 to 7.9932): that pipeline measured element areas in the model's
|
|
110
|
+
Cartesian projection, and this module about each element's own latitude.
|
|
@@ -247,6 +247,84 @@ From Python, either stream records yourself or assemble them in memory:
|
|
|
247
247
|
|
|
248
248
|
ds = fetch_necofs_boundary_conditions("2026-09-26T00:00:00Z", 24, bbox)
|
|
249
249
|
|
|
250
|
+
Full-depth subset on the native mesh
|
|
251
|
+
------------------------------------
|
|
252
|
+
|
|
253
|
+
For work that needs FVCOM's own geometry rather than a regular grid (3D rendering of the sigma
|
|
254
|
+
prisms, particle tracking with FVCOM's own interpolation, comparisons element by element),
|
|
255
|
+
``volume(bbox, start, end)`` returns every sigma layer for the elements whose centroids lie in
|
|
256
|
+
the box, with no regridding and no vertical conversion. ``read_volume(bbox, start_time, hours,
|
|
257
|
+
dap_url=None)`` does the same for one file.
|
|
258
|
+
|
|
259
|
+
.. code-block:: python
|
|
260
|
+
|
|
261
|
+
from forcingkit.fetchers.necofs import volume
|
|
262
|
+
|
|
263
|
+
bbox = [-72.22, 41.10, -71.75, 41.395] # Fishers Island and The Race, with a margin
|
|
264
|
+
vol = volume(bbox, "2026-04-02T00Z", "2026-04-04T00Z") # 49 hourly records
|
|
265
|
+
|
|
266
|
+
.. list-table::
|
|
267
|
+
:header-rows: 1
|
|
268
|
+
:widths: 22 26 52
|
|
269
|
+
|
|
270
|
+
* - Name
|
|
271
|
+
- Dimensions
|
|
272
|
+
- Meaning
|
|
273
|
+
* - ``u``, ``v``, ``ww``
|
|
274
|
+
- (time, siglay, element)
|
|
275
|
+
- Velocities at element layer centres, m s-1. ``ww`` is the vertical velocity in z, which
|
|
276
|
+
FVCOM derives from the sigma-coordinate velocity omega; omega itself is not archived.
|
|
277
|
+
* - ``temp``, ``salinity``
|
|
278
|
+
- (time, siglay, node)
|
|
279
|
+
- At node layer centres.
|
|
280
|
+
* - ``zeta``; ``h``
|
|
281
|
+
- (time, node); (node)
|
|
282
|
+
- Surface elevation; depth below the geoid, positive down.
|
|
283
|
+
* - ``siglay``, ``siglev``
|
|
284
|
+
- (siglay, node), (siglev, node)
|
|
285
|
+
- Sigma at layer centres and interfaces, surface first. The depth of a layer centre is
|
|
286
|
+
:math:`\sigma (h + \zeta) + \zeta`.
|
|
287
|
+
* - ``x``, ``y``; ``xc``, ``yc``
|
|
288
|
+
- (node); (element)
|
|
289
|
+
- The model's own Cartesian coordinates in metres, NAD83 / Maine West (EPSG:26984).
|
|
290
|
+
* - ``node_lon``, ``node_lat``; ``lonc``, ``latc``
|
|
291
|
+
- (node); (element)
|
|
292
|
+
- Degrees.
|
|
293
|
+
* - ``triangles``
|
|
294
|
+
- (element, vertex)
|
|
295
|
+
- Zero-based indices into the returned nodes.
|
|
296
|
+
* - ``neighbours``
|
|
297
|
+
- (element, vertex)
|
|
298
|
+
- The element across the edge opposite each vertex (FVCOM's ``NBE`` order), as an index into
|
|
299
|
+
the returned elements; ``NO_NEIGHBOUR`` (-1) on the mesh boundary, ``OUTSIDE_SUBSET`` (-2)
|
|
300
|
+
where the neighbour exists but was not selected.
|
|
301
|
+
* - ``on_boundary``
|
|
302
|
+
- (node)
|
|
303
|
+
- The node lies on the mesh boundary (coast or open boundary).
|
|
304
|
+
* - ``element_id``, ``node_id``
|
|
305
|
+
- (element); (node)
|
|
306
|
+
- Indices in the full mesh.
|
|
307
|
+
|
|
308
|
+
The archive does not carry FVCOM's edge-neighbour table, so ``neighbours`` and ``on_boundary`` are
|
|
309
|
+
rebuilt from ``nv`` over the **whole** mesh (``forcingkit.mesh_frames.element_neighbours``): on
|
|
310
|
+
the subset alone, every element along the subset's edge would look like a coastal element.
|
|
311
|
+
|
|
312
|
+
Every requested hour must be found, and every archive file must select the same elements;
|
|
313
|
+
otherwise ``volume`` raises. Results are cached as Zarr under
|
|
314
|
+
``$FORCINGKIT_CACHE_DIR/necofs/volume/``, keyed by the schema (``volume-v1``), the box and the
|
|
315
|
+
window. Hyperslabs are fetched with ``FORCINGKIT_MAX_WORKERS`` threads. Even so, the 48 hours
|
|
316
|
+
above (2,831 elements, 1,829 nodes, 45 layers, about 110 MB) took about five minutes from SMAST
|
|
317
|
+
on 2026-10-22, almost all of it server round trips, and 0.1 s from the cache.
|
|
318
|
+
|
|
319
|
+
Wetting and drying
|
|
320
|
+
~~~~~~~~~~~~~~~~~~
|
|
321
|
+
|
|
322
|
+
GOM7 runs with FVCOM's wet/dry treatment, although no file attribute says so and the wet/dry
|
|
323
|
+
masks are not archived. The data show it: the total depth :math:`h + \zeta` never falls below
|
|
324
|
+
0.200 m, thousands of nodes sit exactly at that floor at low water (5,641 in the record of
|
|
325
|
+
2026-04-02 12:00 UTC), and 99 % of the elements whose three nodes are at the floor carry exactly
|
|
326
|
+
zero ``u``, ``v`` and ``ww``. Treat an element as dry when all three of its nodes are at the floor.
|
|
327
|
+
|
|
250
328
|
Notes and limits
|
|
251
329
|
----------------
|
|
252
330
|
|
|
@@ -276,6 +354,13 @@ tag, and the passing of donor options through the dispatcher.
|
|
|
276
354
|
|
|
277
355
|
uv run pytest tests/unit/test_necofs_parent.py -v
|
|
278
356
|
|
|
357
|
+
``tests/unit/test_necofs_volume.py`` covers the full-depth subset on a synthetic FVCOM file:
|
|
358
|
+
every value lands on its own element, node, layer and hour; neighbours are mutual, and the
|
|
359
|
+
subset's edge is never mistaken for the coast; boundary nodes; windows assembled across
|
|
360
|
+
archive files (the midnight record in its own day's file); a missing hour raising; and the
|
|
361
|
+
cache. ``tests/integration/test_necofs_volume_live.py`` reads three hours across a file
|
|
362
|
+
boundary from SMAST.
|
|
363
|
+
|
|
279
364
|
References
|
|
280
365
|
----------
|
|
281
366
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forcingkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.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"
|
|
@@ -595,6 +595,63 @@ def generate_atmosphere(request: AtmosphereRequest) -> Dict[str, Any]:
|
|
|
595
595
|
}
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
class AtmosphereLevelsRequest(BaseModel):
|
|
599
|
+
bbox: BoundingBox
|
|
600
|
+
start_time: str # ISO 8601, UTC
|
|
601
|
+
hours: int
|
|
602
|
+
n_levels: int = 14
|
|
603
|
+
resolution_deg: float = 0.03
|
|
604
|
+
margin_deg: float = 0.25
|
|
605
|
+
cache_bust: bool = False
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@app.post("/api/v1/atmosphere/levels")
|
|
609
|
+
def generate_atmosphere_levels(request: AtmosphereLevelsRequest) -> Dict[str, Any]:
|
|
610
|
+
"""The lower atmosphere above the surface delivery: HRRR wind, vertical velocity and
|
|
611
|
+
temperature on its lowest `n_levels` native levels (14 reach about 2.9 km over water), hourly
|
|
612
|
+
over the same window and grid as /api/v1/atmosphere, streamed to a Zarr store. About 63 MB of
|
|
613
|
+
HRRR is read per hour at 14 levels."""
|
|
614
|
+
from forcingkit.dispatcher import dispatch_levels_request, levels_key
|
|
615
|
+
from forcingkit.fetchers.hrrr_levels import MAX_LEVELS
|
|
616
|
+
|
|
617
|
+
if request.hours < 1:
|
|
618
|
+
raise HTTPException(status_code=400, detail="hours must be at least 1")
|
|
619
|
+
if not 1 <= request.n_levels <= MAX_LEVELS:
|
|
620
|
+
raise HTTPException(
|
|
621
|
+
status_code=400, detail=f"n_levels must be 1 to {MAX_LEVELS}"
|
|
622
|
+
)
|
|
623
|
+
bbox_list = [
|
|
624
|
+
request.bbox.min_lon,
|
|
625
|
+
request.bbox.min_lat,
|
|
626
|
+
request.bbox.max_lon,
|
|
627
|
+
request.bbox.max_lat,
|
|
628
|
+
]
|
|
629
|
+
zarr_id = levels_key(
|
|
630
|
+
bbox_list,
|
|
631
|
+
request.start_time,
|
|
632
|
+
request.hours,
|
|
633
|
+
request.n_levels,
|
|
634
|
+
request.resolution_deg,
|
|
635
|
+
request.margin_deg,
|
|
636
|
+
)
|
|
637
|
+
zarr_path = os.path.join(_atmosphere_cache_dir(), f"{zarr_id}.zarr")
|
|
638
|
+
try:
|
|
639
|
+
final_path = dispatch_levels_request(
|
|
640
|
+
bbox_list,
|
|
641
|
+
request.start_time,
|
|
642
|
+
request.hours,
|
|
643
|
+
zarr_path,
|
|
644
|
+
n_levels=request.n_levels,
|
|
645
|
+
resolution_deg=request.resolution_deg,
|
|
646
|
+
margin_deg=request.margin_deg,
|
|
647
|
+
cache_bust=request.cache_bust,
|
|
648
|
+
)
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.error(f"Atmosphere levels generation failed: {e}")
|
|
651
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
652
|
+
return {"status": "success", "zarr_id": zarr_id, "zarr_path": final_path}
|
|
653
|
+
|
|
654
|
+
|
|
598
655
|
@app.get("/api/v1/atmosphere/download/{zarr_id}")
|
|
599
656
|
def download_atmosphere(zarr_id: str):
|
|
600
657
|
cache_dir = _atmosphere_cache_dir()
|
|
@@ -424,6 +424,82 @@ def dispatch_atmosphere_request(
|
|
|
424
424
|
raise
|
|
425
425
|
|
|
426
426
|
|
|
427
|
+
def levels_key(
|
|
428
|
+
bbox: list[float],
|
|
429
|
+
start_time: str,
|
|
430
|
+
hours: int,
|
|
431
|
+
n_levels: int,
|
|
432
|
+
resolution_deg: float,
|
|
433
|
+
margin_deg: float,
|
|
434
|
+
) -> str:
|
|
435
|
+
"""Cache id of a lower-atmosphere delivery on native levels; the schema is part of it."""
|
|
436
|
+
import hashlib
|
|
437
|
+
|
|
438
|
+
from forcingkit.fetchers.hrrr_levels import HRRR_LEVELS_SCHEMA
|
|
439
|
+
|
|
440
|
+
key = (
|
|
441
|
+
f"lev_hrrr_{HRRR_LEVELS_SCHEMA}_{bbox[0]}_{bbox[1]}_{bbox[2]}_{bbox[3]}_"
|
|
442
|
+
f"{start_time}_{hours}_{n_levels}_{resolution_deg}_{margin_deg}"
|
|
443
|
+
)
|
|
444
|
+
return "lev_" + hashlib.sha256(key.encode()).hexdigest()[:12]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def dispatch_levels_request(
|
|
448
|
+
bbox: list[float],
|
|
449
|
+
start_time: str,
|
|
450
|
+
hours: int,
|
|
451
|
+
zarr_path: str,
|
|
452
|
+
n_levels: int = 14,
|
|
453
|
+
resolution_deg: float = 0.03,
|
|
454
|
+
margin_deg: float = 0.25,
|
|
455
|
+
cache_bust: bool = False,
|
|
456
|
+
) -> str:
|
|
457
|
+
"""HRRR's lowest `n_levels` native levels for `hours` from `start_time`, streamed to
|
|
458
|
+
`zarr_path` (schema hrrr-levels-v1)."""
|
|
459
|
+
from forcingkit.fetchers import hrrr_levels
|
|
460
|
+
from forcingkit.zarr_stream import StreamingZarrWriter, store_is_complete
|
|
461
|
+
|
|
462
|
+
if not cache_bust and store_is_complete(
|
|
463
|
+
zarr_path, (hrrr_levels.HRRR_LEVELS_SCHEMA,)
|
|
464
|
+
):
|
|
465
|
+
logger.info(f"Cache hit for atmosphere levels: {zarr_path}")
|
|
466
|
+
return zarr_path
|
|
467
|
+
|
|
468
|
+
writer = None
|
|
469
|
+
try:
|
|
470
|
+
for item in hrrr_levels.iter_levels(
|
|
471
|
+
start_time,
|
|
472
|
+
hours,
|
|
473
|
+
bbox,
|
|
474
|
+
n_levels=n_levels,
|
|
475
|
+
resolution_deg=resolution_deg,
|
|
476
|
+
margin_deg=margin_deg,
|
|
477
|
+
):
|
|
478
|
+
if item[0] == "static":
|
|
479
|
+
writer = StreamingZarrWriter(
|
|
480
|
+
zarr_path,
|
|
481
|
+
item[1],
|
|
482
|
+
hrrr_levels.RECORD_DIMS,
|
|
483
|
+
expected_records=hours + 3,
|
|
484
|
+
attrs=dict(item[1].attrs),
|
|
485
|
+
record_attrs={
|
|
486
|
+
k: {"units": u, "standard_name": sn}
|
|
487
|
+
for k, (u, sn) in hrrr_levels.UNITS.items()
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
continue
|
|
491
|
+
if writer is None:
|
|
492
|
+
raise RuntimeError("level records arrived before the static fields")
|
|
493
|
+
writer.append(item[1], item[2])
|
|
494
|
+
if writer is None:
|
|
495
|
+
raise RuntimeError("HRRR yielded no data")
|
|
496
|
+
return writer.close()
|
|
497
|
+
except Exception:
|
|
498
|
+
if writer is not None:
|
|
499
|
+
writer.abort()
|
|
500
|
+
raise
|
|
501
|
+
|
|
502
|
+
|
|
427
503
|
PROVISIONAL_MAX_AGE_HOURS = 24
|
|
428
504
|
|
|
429
505
|
|