forcingkit 0.9.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.
Files changed (118) hide show
  1. {forcingkit-0.9.0 → forcingkit-0.11.0}/PKG-INFO +1 -1
  2. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/conf.py +1 -1
  3. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/fetchers.rst +9 -0
  4. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/index.rst +6 -0
  5. forcingkit-0.11.0/docs/source/layered_mesh.rst +110 -0
  6. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/mesh_frames.rst +11 -5
  7. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/necofs.rst +85 -0
  8. {forcingkit-0.9.0 → forcingkit-0.11.0}/pyproject.toml +1 -1
  9. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/main.py +57 -0
  10. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/dispatcher.py +76 -0
  11. forcingkit-0.11.0/src/forcingkit/fetchers/hrrr_levels.py +265 -0
  12. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/necofs.py +339 -0
  13. forcingkit-0.11.0/src/forcingkit/layered_mesh.py +865 -0
  14. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/mesh_frames.py +22 -0
  15. forcingkit-0.11.0/src/forcingkit/ramps/currents_lis_deep.csv +71 -0
  16. forcingkit-0.11.0/src/forcingkit/ramps/currents_lis_haline.csv +70 -0
  17. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/render.py +4 -0
  18. forcingkit-0.11.0/tests/integration/test_necofs_volume_live.py +37 -0
  19. forcingkit-0.11.0/tests/unit/test_hrrr_levels.py +149 -0
  20. forcingkit-0.11.0/tests/unit/test_layered_mesh.py +434 -0
  21. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_mesh_frames.py +23 -1
  22. forcingkit-0.11.0/tests/unit/test_necofs_volume.py +312 -0
  23. {forcingkit-0.9.0 → forcingkit-0.11.0}/uv.lock +1 -1
  24. {forcingkit-0.9.0 → forcingkit-0.11.0}/.agents/forcingkit.md +0 -0
  25. {forcingkit-0.9.0 → forcingkit-0.11.0}/.claude/CLAUDE.md +0 -0
  26. {forcingkit-0.9.0 → forcingkit-0.11.0}/.env.template +0 -0
  27. {forcingkit-0.9.0 → forcingkit-0.11.0}/.github/copilot-instructions.md +0 -0
  28. {forcingkit-0.9.0 → forcingkit-0.11.0}/.github/workflows/docs.yml +0 -0
  29. {forcingkit-0.9.0 → forcingkit-0.11.0}/.github/workflows/publish.yml +0 -0
  30. {forcingkit-0.9.0 → forcingkit-0.11.0}/.github/workflows/tests.yml +0 -0
  31. {forcingkit-0.9.0 → forcingkit-0.11.0}/.gitignore +0 -0
  32. {forcingkit-0.9.0 → forcingkit-0.11.0}/.markdownlint.yaml +0 -0
  33. {forcingkit-0.9.0 → forcingkit-0.11.0}/.pre-commit-config.yaml +0 -0
  34. {forcingkit-0.9.0 → forcingkit-0.11.0}/.python-version +0 -0
  35. {forcingkit-0.9.0 → forcingkit-0.11.0}/AGENTS.md +0 -0
  36. {forcingkit-0.9.0 → forcingkit-0.11.0}/CONTRIBUTING.md +0 -0
  37. {forcingkit-0.9.0 → forcingkit-0.11.0}/Dockerfile +0 -0
  38. {forcingkit-0.9.0 → forcingkit-0.11.0}/LICENSE +0 -0
  39. {forcingkit-0.9.0 → forcingkit-0.11.0}/README.md +0 -0
  40. {forcingkit-0.9.0 → forcingkit-0.11.0}/docker-compose.yml +0 -0
  41. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/Makefile +0 -0
  42. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/make.bat +0 -0
  43. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/requirements-docs.txt +0 -0
  44. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/_extra/CNAME +0 -0
  45. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/_static/custom.css +0 -0
  46. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  47. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/atmospheric_forcing.rst +0 -0
  48. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/hycom.rst +0 -0
  49. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/nyofs.rst +0 -0
  50. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/rivers.rst +0 -0
  51. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/roadmap.rst +0 -0
  52. {forcingkit-0.9.0 → forcingkit-0.11.0}/docs/source/sources.rst +0 -0
  53. {forcingkit-0.9.0 → forcingkit-0.11.0}/main.py +0 -0
  54. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/__init__.py +0 -0
  55. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/__init__.py +0 -0
  56. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  57. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  58. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/removed.py +0 -0
  59. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/forcingkit_serve/routers/viewer.py +0 -0
  60. {forcingkit-0.9.0 → forcingkit-0.11.0}/service/run_server.py +0 -0
  61. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/__init__.py +0 -0
  62. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/data/rivers/northeast_us.json +0 -0
  63. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/coastwatch.py +0 -0
  64. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  65. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/erddap.py +0 -0
  66. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  67. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  68. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hrrr_points.py +0 -0
  69. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hycom.py +0 -0
  70. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  71. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  72. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/noaa.py +0 -0
  73. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  74. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/usgs.py +0 -0
  75. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
  76. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis.csv +0 -0
  77. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/ramps/currents_lis_dark.csv +0 -0
  78. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/settings.py +0 -0
  79. {forcingkit-0.9.0 → forcingkit-0.11.0}/src/forcingkit/zarr_stream.py +0 -0
  80. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/app.js +0 -0
  81. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/favicon.ico +0 -0
  82. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/favicon.svg +0 -0
  83. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/index.html +0 -0
  84. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/logo.svg +0 -0
  85. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/preview3d.js +0 -0
  86. {forcingkit-0.9.0 → forcingkit-0.11.0}/static/styles.css +0 -0
  87. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/__init__.py +0 -0
  88. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_auth.py +0 -0
  89. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_erddap_fetch.py +0 -0
  90. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_mesh_frames_necofs.py +0 -0
  91. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  92. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_obc_offshore_nj.py +0 -0
  93. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_ofs_archive_paths.py +0 -0
  94. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  95. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/scripts/inspect_dem.py +0 -0
  96. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/scripts/inspect_grib.py +0 -0
  97. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/scripts/inspect_zarr.py +0 -0
  98. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_coastwatch.py +0 -0
  99. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_dbofs.py +0 -0
  100. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_dispatcher.py +0 -0
  101. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_erddap.py +0 -0
  102. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_atm.py +0 -0
  103. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_idx.py +0 -0
  104. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_hrrr_points.py +0 -0
  105. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_hycom.py +0 -0
  106. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_main.py +0 -0
  107. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_ndbc.py +0 -0
  108. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_necofs_parent.py +0 -0
  109. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_noaa_currents.py +0 -0
  110. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_noaa_datum.py +0 -0
  111. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_nyofs.py +0 -0
  112. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_obc_donor_report.py +0 -0
  113. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_obc_pipeline.py +0 -0
  114. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_removed_routes.py +0 -0
  115. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_settings.py +0 -0
  116. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_usgs.py +0 -0
  117. {forcingkit-0.9.0 → forcingkit-0.11.0}/tests/unit/test_usgs_rivers.py +0 -0
  118. {forcingkit-0.9.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.9.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
@@ -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.9.0"
12
+ release = "0.11.0"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -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.
@@ -45,12 +45,18 @@ Colour
45
45
  last row take the last colour; NaN is transparent. ``load_lut_csv(path)`` reads such a table
46
46
  from a ``value,r,g,b[,a]`` CSV;
47
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
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
50
  knots and their source;
51
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.
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.
54
60
 
55
61
  Python
56
62
  ------
@@ -83,7 +89,7 @@ Command line
83
89
  --size 1200x800 --ramp viridis --vmax 1.5 \
84
90
  --edges 0,0,0,40 --background 242,243,240 --out frames/
85
91
 
86
- ``--ramp`` takes a built-in name, a packaged table (``currents_lis``, ``currents_lis_dark``) or a path to a
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
87
93
  ``value,r,g,b[,a]`` CSV (with ``--log`` and ``--floor``). The output directory receives
88
94
  ``frame_0000.png``, ``frame_0001.png``, ... and ``frames.json``, which lists the frame times, the box
89
95
  and the size.
@@ -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.9.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