forcingkit 0.1.0.post1__tar.gz → 0.2.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 (85) hide show
  1. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/PKG-INFO +4 -2
  2. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/README.md +3 -1
  3. forcingkit-0.2.0/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  4. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/conf.py +1 -1
  5. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/removed_endpoints.rst +1 -1
  6. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/pyproject.toml +1 -1
  7. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/main.py +1 -1
  8. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/removed.py +1 -1
  9. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/viewer.py +7 -0
  10. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/necofs.py +52 -8
  11. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/noaa.py +22 -5
  12. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/app.js +40 -0
  13. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/index.html +5 -2
  14. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/preview3d.js +74 -6
  15. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_necofs_parent.py +44 -2
  16. forcingkit-0.2.0/tests/unit/test_noaa_datum.py +66 -0
  17. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/uv.lock +1 -1
  18. forcingkit-0.1.0.post1/docs/source/_static/forcingkit-inventory-screenshot.png +0 -0
  19. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.agents/forcingkit.md +0 -0
  20. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.claude/CLAUDE.md +0 -0
  21. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.env.template +0 -0
  22. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/copilot-instructions.md +0 -0
  23. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/docs.yml +0 -0
  24. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/publish.yml +0 -0
  25. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.github/workflows/tests.yml +0 -0
  26. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.gitignore +0 -0
  27. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.markdownlint.yaml +0 -0
  28. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.pre-commit-config.yaml +0 -0
  29. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/.python-version +0 -0
  30. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/AGENTS.md +0 -0
  31. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/CONTRIBUTING.md +0 -0
  32. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/Dockerfile +0 -0
  33. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/LICENSE +0 -0
  34. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docker-compose.yml +0 -0
  35. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/Makefile +0 -0
  36. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/make.bat +0 -0
  37. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/requirements-docs.txt +0 -0
  38. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/_extra/CNAME +0 -0
  39. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/atmospheric_forcing.rst +0 -0
  40. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/fetchers.rst +0 -0
  41. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/index.rst +0 -0
  42. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/docs/source/nyofs.rst +0 -0
  43. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/main.py +0 -0
  44. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/__init__.py +0 -0
  45. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/__init__.py +0 -0
  46. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  47. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  48. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/service/run_server.py +0 -0
  49. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/__init__.py +0 -0
  50. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/dispatcher.py +0 -0
  51. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/dbofs.py +0 -0
  52. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/erddap.py +0 -0
  53. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hrrr.py +0 -0
  54. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  55. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hycom.py +0 -0
  56. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/hydrography.py +0 -0
  57. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/ndbc.py +0 -0
  58. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/fetchers/nyofs.py +0 -0
  59. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/settings.py +0 -0
  60. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/src/forcingkit/zarr_stream.py +0 -0
  61. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/favicon.ico +0 -0
  62. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/favicon.svg +0 -0
  63. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/logo.svg +0 -0
  64. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/static/styles.css +0 -0
  65. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/__init__.py +0 -0
  66. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_auth.py +0 -0
  67. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_erddap_fetch.py +0 -0
  68. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  69. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_obc_mab.py +0 -0
  70. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/integration/test_s3_roms_fetchers.py +0 -0
  71. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_dem.py +0 -0
  72. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_grib.py +0 -0
  73. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/scripts/inspect_zarr.py +0 -0
  74. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_dbofs.py +0 -0
  75. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_dispatcher.py +0 -0
  76. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hrrr_atm.py +0 -0
  77. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hrrr_idx.py +0 -0
  78. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_hycom.py +0 -0
  79. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_main.py +0 -0
  80. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_ndbc.py +0 -0
  81. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_nyofs.py +0 -0
  82. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_obc_pipeline.py +0 -0
  83. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_removed_routes.py +0 -0
  84. {forcingkit-0.1.0.post1 → forcingkit-0.2.0}/tests/unit/test_settings.py +0 -0
  85. {forcingkit-0.1.0.post1 → forcingkit-0.2.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.1.0.post1
3
+ Version: 0.2.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
@@ -246,7 +246,9 @@ Description-Content-Type: text/markdown
246
246
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
247
247
 
248
248
  <div align="center">
249
- <img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-inventory-screenshot.png" alt="forcingkit Dashboard" />
249
+ <img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-noreaster-wind.gif" alt="Animation of the HRRR 10 m wind from the NY Bight to Cape Cod, every 3 hours from 2026-09-22 00:00 to 2026-09-29 12:00 UTC" width="720" />
250
+ <br />
251
+ <sub>HRRR 10 m wind served by forcingkit through the 26-27 September 2026 nor'easter, NY Bight to Cape Cod, from the calm of 22 September to the calm of 29 September, every 3 hours (peak 25.9 m/s). The forcingkit viewer's time slider, played.</sub>
250
252
  </div>
251
253
 
252
254
  Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
@@ -5,7 +5,9 @@
5
5
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
6
 
7
7
  <div align="center">
8
- <img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-inventory-screenshot.png" alt="forcingkit Dashboard" />
8
+ <img src="https://raw.githubusercontent.com/lhzn-io/forcingkit/main/docs/source/_static/forcingkit-noreaster-wind.gif" alt="Animation of the HRRR 10 m wind from the NY Bight to Cape Cod, every 3 hours from 2026-09-22 00:00 to 2026-09-29 12:00 UTC" width="720" />
9
+ <br />
10
+ <sub>HRRR 10 m wind served by forcingkit through the 26-27 September 2026 nor'easter, NY Bight to Cape Cod, from the calm of 22 September to the calm of 29 September, every 3 hours (peak 25.9 m/s). The forcingkit viewer's time slider, played.</sub>
9
11
  </div>
10
12
 
11
13
  Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.
@@ -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.1.0.post1"
12
+ release = "0.2.0"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -18,7 +18,7 @@ and the routes then answer 404.
18
18
  * - Removed
19
19
  - Use instead
20
20
  * - ``POST /api/v1/ic/generate``, ``POST /api/v1/ic/regrid``
21
- - ``POST /api/v1/obc``: the parent ocean on true z (schema ``z-v2``). Its first record is
21
+ - ``POST /api/v1/obc``: the parent ocean on true z (schema ``z-v3``). Its first record is
22
22
  the initial condition, already on regular lon/lat and fixed z levels, so no separate
23
23
  regrid step remains.
24
24
  * - ``POST /api/v1/ic/cache``, ``POST /api/v1/ic/predict-donor``,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forcingkit"
7
- version = "0.1.0.post1"
7
+ version = "0.2.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"
@@ -131,7 +131,7 @@ class OBCRequest(BaseModel):
131
131
  sponge_cells: int = 0
132
132
  include_tides: bool = True
133
133
  tidal_model: str = "GOT4.10c"
134
- # Parent-ocean options (schema z-v2): donor cells of padding beyond the bbox, so the parent
134
+ # Parent-ocean options (schema z-v3): donor cells of padding beyond the bbox, so the parent
135
135
  # brackets the child, and the spacing of the fixed z levels.
136
136
  pad_cells: int = 3
137
137
  vertical_spacing_m: float = 2.0
@@ -19,7 +19,7 @@ REMOVED_ON = datetime(2026, 10, 5, tzinfo=timezone.utc)
19
19
  DOCS_URL = "https://forcingkit.docs.lhzn.io/removed_endpoints.html"
20
20
 
21
21
  _PARENT = (
22
- "POST /api/v1/obc: the parent ocean on true z (schema z-v2); its first record is the "
22
+ "POST /api/v1/obc: the parent ocean on true z (schema z-v3); its first record is the "
23
23
  "initial condition"
24
24
  )
25
25
  _ATMOSPHERE = (
@@ -912,10 +912,16 @@ async def get_dataset_preview_3d(
912
912
 
913
913
  # Select time slice
914
914
  time_name = next((n for n in ["time", "ocean_time", "t"] if n in ds.dims), None)
915
+ frame_time = None
915
916
  if time_name and time_name in u_da.dims:
916
917
  t_idx = min(time_idx, u_da.sizes[time_name] - 1)
917
918
  u_da = u_da.isel({time_name: t_idx})
918
919
  v_da = v_da.isel({time_name: t_idx})
920
+ # The record's time, for the viewer's label (UTC, to the minute).
921
+ if time_name in ds.coords and ds[time_name].dtype.kind == "M":
922
+ frame_time = (
923
+ str(ds[time_name].values[t_idx].astype("datetime64[m]")) + "Z"
924
+ )
919
925
 
920
926
  # Find the best lon/lat coordinate for each variable.
921
927
  # Staggered ROMS grids have per-variable coords (lon_u/lat_u, lon_v/lat_v).
@@ -1103,6 +1109,7 @@ async def get_dataset_preview_3d(
1103
1109
  "bounds": [min(all_lons), min(all_lats), max(all_lons), max(all_lats)],
1104
1110
  "depth_levels": sorted(set(v["depth"] for v in vectors)),
1105
1111
  "count": len(vectors),
1112
+ "time": frame_time,
1106
1113
  }
1107
1114
 
1108
1115
  return Response(
@@ -60,7 +60,7 @@ def get_necofs_url(target_dt: pd.Timestamp) -> str:
60
60
 
61
61
  # Parent-ocean delivery schema. Bump when the layout or meaning of the boundary store changes, so
62
62
  # a cached store built under an older schema can never be served for a newer request.
63
- OBC_SCHEMA = "z-v2"
63
+ OBC_SCHEMA = "z-v3"
64
64
 
65
65
 
66
66
  def necofs_archive_file_date(t: pd.Timestamp) -> pd.Timestamp:
@@ -145,6 +145,11 @@ class Barycentric:
145
145
  self.vertices = tri.simplices[s]
146
146
  self.shape = shape
147
147
 
148
+ def restrict(self, inside: np.ndarray) -> "Barycentric":
149
+ """Also treat targets outside `inside` (flat, one per target) as outside."""
150
+ self.inside = self.inside & inside
151
+ return self
152
+
148
153
  def __call__(self, values: np.ndarray) -> np.ndarray:
149
154
  values = np.asarray(values, dtype=np.float64)
150
155
  out = np.einsum("...pk,pk->...p", values[..., self.vertices], self.weights)
@@ -152,6 +157,44 @@ class Barycentric:
152
157
  return out.reshape(values.shape[:-1] + self.shape)
153
158
 
154
159
 
160
+ class MeshBarycentric(Barycentric):
161
+ """Linear interpolation on the triangles of an unstructured mesh, as FVCOM itself interpolates
162
+ node values within an element.
163
+
164
+ A Delaunay triangulation of the mesh nodes also covers land between them (a peninsula, an
165
+ island, the coast between two estuaries) and fills it with values blended across it. Here a
166
+ target takes values only from the mesh element that contains it, and a target inside no
167
+ element (land, or outside the mesh) is NaN. `triangles` are zero-based node indices, one row
168
+ per element.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ lon: np.ndarray,
174
+ lat: np.ndarray,
175
+ triangles: np.ndarray,
176
+ targets: np.ndarray,
177
+ shape: tuple[int, ...],
178
+ ):
179
+ from matplotlib.tri import Triangulation
180
+
181
+ mesh = Triangulation(lon, lat, triangles)
182
+ element = np.asarray(mesh.get_trifinder()(targets[:, 0], targets[:, 1]))
183
+ self.inside = element >= 0
184
+ self.vertices = triangles[np.where(self.inside, element, 0)]
185
+ x, y = lon[self.vertices], lat[self.vertices]
186
+ # Barycentric coordinates of each target in its element.
187
+ det = (y[:, 1] - y[:, 2]) * (x[:, 0] - x[:, 2]) + (x[:, 2] - x[:, 1]) * (
188
+ y[:, 0] - y[:, 2]
189
+ )
190
+ det = np.where(self.inside, det, 1.0)
191
+ dx, dy = targets[:, 0] - x[:, 2], targets[:, 1] - y[:, 2]
192
+ b0 = ((y[:, 1] - y[:, 2]) * dx + (x[:, 2] - x[:, 1]) * dy) / det
193
+ b1 = ((y[:, 2] - y[:, 0]) * dx + (x[:, 0] - x[:, 2]) * dy) / det
194
+ self.weights = np.column_stack([b0, b1, 1.0 - b0 - b1])
195
+ self.shape = shape
196
+
197
+
155
198
  PARENT_RECORD_DIMS = {
156
199
  "u": ("z", "lat", "lon"),
157
200
  "v": ("z", "lat", "lon"),
@@ -212,7 +255,9 @@ def iter_parent(
212
255
  parent brackets the child. The vertical axis is true depth: each FVCOM sigma layer is placed at
213
256
  z = siglay * (h + zeta) + zeta for its column and interpolated onto fixed levels every
214
257
  `vertical_spacing_m` metres, ordered bottom to top. Land, points outside the mesh and levels
215
- below the sea floor are NaN.
258
+ below the sea floor are NaN. Land is what lies inside no element of the NECOFS mesh: node
259
+ fields are interpolated within the containing element, and element fields (u, v) are NaN
260
+ wherever the node mesh is.
216
261
 
217
262
  Raises rather than returning a short record: an unreachable archive file, a missing hour or a
218
263
  failed interpolation ends the delivery with an error.
@@ -259,16 +304,15 @@ def iter_parent(
259
304
  logger.info(
260
305
  "Building NECOFS parent interpolation weights and vertical grid..."
261
306
  )
262
- at_node = Barycentric(
263
- Delaunay(np.column_stack((ds["lon"].values, ds["lat"].values))),
264
- target_pts,
265
- shape,
266
- )
307
+ node_lon = np.asarray(ds["lon"].values, dtype=np.float64)
308
+ node_lat = np.asarray(ds["lat"].values, dtype=np.float64)
309
+ triangles = np.asarray(ds["nv"].values).T.astype(np.int64) - 1
310
+ at_node = MeshBarycentric(node_lon, node_lat, triangles, target_pts, shape)
267
311
  at_elem = Barycentric(
268
312
  Delaunay(np.column_stack((ds["lonc"].values, ds["latc"].values))),
269
313
  target_pts,
270
314
  shape,
271
- )
315
+ ).restrict(at_node.inside)
272
316
  h_grid = at_node(ds["h"].values)
273
317
  siglay_grid = at_node(np.asarray(ds["siglay"].values)) # surface first
274
318
  if not np.any(np.isfinite(h_grid)):
@@ -8,6 +8,10 @@ from forcingkit import settings
8
8
  logger = logging.getLogger(__name__)
9
9
 
10
10
 
11
+ # Water-level datums to request, in order of preference.
12
+ TIDE_DATUMS = ("NAVD", "MSL")
13
+
14
+
11
15
  def fetch_noaa_tide_data(
12
16
  station_id: str,
13
17
  start_time: str,
@@ -57,7 +61,6 @@ def fetch_noaa_tide_data(
57
61
  "end_date": end_str,
58
62
  "station": station_id,
59
63
  "product": "water_level",
60
- "datum": "NAVD",
61
64
  "units": "metric",
62
65
  "time_zone": "gmt",
63
66
  "format": "json",
@@ -65,16 +68,30 @@ def fetch_noaa_tide_data(
65
68
  }
66
69
 
67
70
  try:
68
- response = requests.get(url, params=params, timeout=30)
69
- response.raise_for_status()
70
- data = response.json()
71
+ # NAVD88 where the station has it, so stations share a datum; otherwise MSL (New Haven,
72
+ # 8465705, publishes only tidal datums). The datum used is recorded in the metadata.
73
+ for datum in TIDE_DATUMS:
74
+ response = requests.get(url, params={**params, "datum": datum}, timeout=30)
75
+ data = response.json() if response.content else {}
76
+ message = (
77
+ data.get("error", {}).get("message", "")
78
+ if isinstance(data, dict)
79
+ else ""
80
+ )
81
+ if "datum" in message.lower() and datum != TIDE_DATUMS[-1]:
82
+ logger.info(
83
+ f"NOAA station {station_id} has no {datum} datum; trying the next"
84
+ )
85
+ continue
86
+ response.raise_for_status()
87
+ break
71
88
 
72
89
  if "error" in data:
73
90
  logger.warning(
74
91
  f"NOAA API returned error: {data['error'].get('message', 'Unknown error')}"
75
92
  )
76
- # Return a dummy fallback or raise? For now, we'll raise to let dispatcher handle it
77
93
  raise RuntimeError(f"NOAA API Error: {data['error'].get('message')}")
94
+ data.setdefault("metadata", {})["datum"] = datum
78
95
 
79
96
  # Cache the successful response
80
97
  with open(cache_path, "w") as f:
@@ -316,8 +316,10 @@ document.addEventListener("DOMContentLoaded", () => {
316
316
  document.querySelectorAll(".dataset-card").forEach(c => c.classList.remove("selected"));
317
317
  cardElement.classList.add("selected");
318
318
 
319
+ setPlaying(false);
319
320
  selectedDataset = item;
320
321
  previewControls.classList.remove("hidden");
322
+ frameTime.textContent = "";
321
323
  timeRange.value = 0;
322
324
  timeIdxDisplay.textContent = "0";
323
325
 
@@ -434,6 +436,43 @@ document.addEventListener("DOMContentLoaded", () => {
434
436
  previewDebounce = setTimeout(updatePreview, 300);
435
437
  });
436
438
 
439
+ // Auto-play: step the time slider, waiting for each frame to render, and loop.
440
+ const playBtn = document.getElementById("playBtn");
441
+ const frameTime = document.getElementById("frameTime");
442
+ const FRAME_MS = 400; // the fastest step; slower frames take as long as they take to load
443
+ let playing = false;
444
+
445
+ function setPlaying(on) {
446
+ playing = on;
447
+ if (playBtn) playBtn.textContent = on ? "Pause" : "Play";
448
+ }
449
+
450
+ async function playLoop() {
451
+ while (playing && selectedDataset) {
452
+ const last = parseInt(timeRange.max) || 0;
453
+ if (last < 1) break;
454
+ const next = (parseInt(timeRange.value) + 1) % (last + 1);
455
+ const started = performance.now();
456
+ timeRange.value = next;
457
+ if (window.currentDataCube && window.currentDataCube[next]) {
458
+ timeRange.dispatchEvent(new Event("input"));
459
+ } else {
460
+ timeIdxDisplay.textContent = `${next} (${selectedDataset.time_steps || '?'})`;
461
+ await updatePreview();
462
+ }
463
+ await new Promise(r => setTimeout(r, Math.max(0, FRAME_MS - (performance.now() - started))));
464
+ }
465
+ setPlaying(false);
466
+ }
467
+
468
+ if (playBtn) {
469
+ playBtn.addEventListener("click", () => {
470
+ if (playing) { setPlaying(false); return; }
471
+ setPlaying(true);
472
+ playLoop();
473
+ });
474
+ }
475
+
437
476
  const lodSelect = document.getElementById("lodSelect");
438
477
  if (lodSelect) {
439
478
  lodSelect.addEventListener("change", () => {
@@ -467,6 +506,7 @@ document.addEventListener("DOMContentLoaded", () => {
467
506
 
468
507
  try {
469
508
  const info = await window.preview3d.load(id, parseInt(t));
509
+ frameTime.textContent = info.time ? info.time.replace("T", " ").replace("Z", " UTC") : "";
470
510
  console.log(`3D preview: ${info.count} vectors (${info.u_var}/${info.v_var}), ${info.depths} depth levels`);
471
511
  } catch (err) {
472
512
  console.error("3D preview failed:", err);
@@ -101,8 +101,11 @@
101
101
  </div>
102
102
  </div>
103
103
  <div id="timeSliderGroup" class="control-group">
104
- <label for="timeRange">Time Index (<span id="timeIdxDisplay">0</span>)</label>
105
- <input type="range" id="timeRange" min="0" max="48" value="0" class="form-range">
104
+ <label for="timeRange">Time Index (<span id="timeIdxDisplay">0</span>) <span id="frameTime" style="color:#8b949e;"></span></label>
105
+ <div style="display:flex;align-items:center;gap:8px;">
106
+ <button id="playBtn" type="button" class="btn" title="Step through the time steps and loop">Play</button>
107
+ <input type="range" id="timeRange" min="0" max="48" value="0" class="form-range" style="flex:1;">
108
+ </div>
106
109
  </div>
107
110
  <div id="snapshotLabel" class="control-group" style="display:none;">
108
111
  <span style="color:#8b949e; font-size:0.85rem; font-style:italic;" id="snapshotLabelText"></span>
@@ -8,6 +8,11 @@ let camera = null;
8
8
  let controls = null;
9
9
  let arrowGroup = null;
10
10
  let animId = null;
11
+ let legendHost = null;
12
+ // The dataset on screen and the largest speed seen in it, so stepping through time keeps the
13
+ // camera where the user put it and colours that compare across frames.
14
+ let currentDatasetId = null;
15
+ let scaleMax = 0;
11
16
 
12
17
  // - Depth exaggeration (matches hydro viewer: 10x base * slider) -
13
18
  const DEPTH_EXAG = 8.0; // equivalent to hydro viewer slider default
@@ -46,6 +51,55 @@ function depthColor(mag, maxMag, depthFrac) {
46
51
  }
47
52
 
48
53
  // - Merge simple geometries -
54
+ // - Color map for single-level fields (atmosphere, or a surface-only ocean field): speed -
55
+ // Plasma stops (matplotlib), perceptually uniform and legible on the dark background.
56
+ const PLASMA = [
57
+ [0.00, [0.050, 0.031, 0.529]],
58
+ [0.25, [0.494, 0.012, 0.659]],
59
+ [0.50, [0.800, 0.278, 0.471]],
60
+ [0.75, [0.973, 0.584, 0.251]],
61
+ [1.00, [0.941, 0.976, 0.129]],
62
+ ];
63
+
64
+ function speedColor(mag, maxMag) {
65
+ const t = Math.min(1, mag / Math.max(maxMag, 0.001));
66
+ for (let i = 1; i < PLASMA.length; i++) {
67
+ const [t1, c1] = PLASMA[i];
68
+ if (t <= t1) {
69
+ const [t0, c0] = PLASMA[i - 1];
70
+ const s = (t - t0) / (t1 - t0);
71
+ return new THREE.Color(
72
+ c0[0] + (c1[0] - c0[0]) * s,
73
+ c0[1] + (c1[1] - c0[1]) * s,
74
+ c0[2] + (c1[2] - c0[2]) * s,
75
+ );
76
+ }
77
+ }
78
+ return new THREE.Color(...PLASMA[PLASMA.length - 1][1]);
79
+ }
80
+
81
+ // A speed legend over the 3D canvas: the plasma ramp from 0 to the frame's maximum speed.
82
+ function showSpeedLegend(maxMag) {
83
+ hideSpeedLegend();
84
+ if (!legendHost) return;
85
+ if (getComputedStyle(legendHost).position === 'static') legendHost.style.position = 'relative';
86
+ const stops = PLASMA.map(([s, c]) =>
87
+ `rgb(${c.map(x => Math.round(x * 255)).join(',')}) ${s * 100}%`).join(', ');
88
+ const el = document.createElement('div');
89
+ el.id = 'preview3dLegend';
90
+ el.style.cssText = 'position:absolute;right:16px;bottom:16px;padding:8px 10px;border-radius:6px;' +
91
+ 'background:rgba(13,17,23,0.75);color:#c9d1d9;font:12px system-ui,sans-serif;pointer-events:none;';
92
+ el.innerHTML = `<div style="margin-bottom:4px">Speed (m/s)</div>` +
93
+ `<div style="width:160px;height:10px;border-radius:2px;background:linear-gradient(to right, ${stops})"></div>` +
94
+ `<div style="display:flex;justify-content:space-between;margin-top:2px"><span>0</span><span>${maxMag.toFixed(1)}</span></div>`;
95
+ legendHost.appendChild(el);
96
+ }
97
+
98
+ function hideSpeedLegend() {
99
+ const el = document.getElementById('preview3dLegend');
100
+ if (el) el.remove();
101
+ }
102
+
49
103
  function mergeGeos(geos) {
50
104
  let totalV = 0, allIdx = [];
51
105
  for (const g of geos) { totalV += g.attributes.position.count; }
@@ -69,6 +123,7 @@ function mergeGeos(geos) {
69
123
  }
70
124
 
71
125
  function initThree(container) {
126
+ legendHost = container;
72
127
  const canvas = document.createElement('canvas');
73
128
  canvas.id = 'preview3dCanvas';
74
129
  container.appendChild(canvas);
@@ -120,6 +175,7 @@ function initThree(container) {
120
175
  }
121
176
 
122
177
  function clearArrows() {
178
+ hideSpeedLegend();
123
179
  if (arrowGroup) {
124
180
  scene.remove(arrowGroup);
125
181
  arrowGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); });
@@ -127,7 +183,7 @@ function clearArrows() {
127
183
  }
128
184
  }
129
185
 
130
- function buildVectorField(data) {
186
+ function buildVectorField(data, fitCamera) {
131
187
  clearArrows();
132
188
  if (!data.vectors || data.vectors.length === 0) return;
133
189
 
@@ -151,6 +207,8 @@ function buildVectorField(data) {
151
207
  const mag = Math.sqrt(v.u * v.u + v.v * v.v);
152
208
  if (mag > maxMag) maxMag = mag;
153
209
  }
210
+ scaleMax = Math.max(scaleMax, maxMag);
211
+ maxMag = scaleMax;
154
212
 
155
213
  // Build arrow geometry (shaft + cone)
156
214
  const shaft = new THREE.CylinderGeometry(0.12, 0.12, 1, 6);
@@ -162,8 +220,13 @@ function buildVectorField(data) {
162
220
  const arrowGeo = mergeGeos([shaft, cone]);
163
221
 
164
222
  const count = data.vectors.length;
223
+ // One level (an atmosphere, or a surface-only ocean field): colour by speed, not depth.
224
+ const singleLevel = !data.depth_levels || data.depth_levels.length <= 1;
165
225
  // depthTest: false so subsurface arrows render through the grid plane
166
- const mat = new THREE.MeshPhongMaterial({ flatShading: true, depthTest: false });
226
+ // Unlit for a single level, so the speed colour map shows true; shaded for depth layers.
227
+ const mat = singleLevel
228
+ ? new THREE.MeshBasicMaterial({ depthTest: false })
229
+ : new THREE.MeshPhongMaterial({ flatShading: true, depthTest: false });
167
230
  const mesh = new THREE.InstancedMesh(arrowGeo, mat, count);
168
231
  mesh.renderOrder = 10;
169
232
 
@@ -187,7 +250,7 @@ function buildVectorField(data) {
187
250
  dummy.scale.set(arrowLen, arrowLen, arrowLen);
188
251
  dummy.updateMatrix();
189
252
  mesh.setMatrixAt(i, dummy.matrix);
190
- mesh.setColorAt(i, depthColor(mag, maxMag, v.depth_frac || 0));
253
+ mesh.setColorAt(i, singleLevel ? speedColor(mag, maxMag) : depthColor(mag, maxMag, v.depth_frac || 0));
191
254
  }
192
255
 
193
256
  mesh.instanceMatrix.needsUpdate = true;
@@ -211,8 +274,10 @@ function buildVectorField(data) {
211
274
  }
212
275
 
213
276
  scene.add(arrowGroup);
277
+ if (singleLevel) showSpeedLegend(maxMag);
214
278
 
215
- // Fit camera to vector field bounds
279
+ // Fit camera to vector field bounds, only for a newly selected dataset
280
+ if (!fitCamera) return;
216
281
  const box = new THREE.Box3().setFromObject(arrowGroup);
217
282
  const center = box.getCenter(new THREE.Vector3());
218
283
  const size = box.getSize(new THREE.Vector3());
@@ -242,6 +307,7 @@ window.preview3d = {
242
307
  const canvas = document.getElementById('preview3dCanvas');
243
308
  if (canvas) canvas.style.display = 'none';
244
309
  this.active = false;
310
+ currentDatasetId = null;
245
311
  clearArrows();
246
312
  },
247
313
 
@@ -254,8 +320,10 @@ window.preview3d = {
254
320
  throw new Error(err.detail || `HTTP ${res.status}`);
255
321
  }
256
322
  const data = await res.json();
257
- buildVectorField(data);
258
- return { count: data.count, u_var: data.u_var, v_var: data.v_var, depths: data.depth_levels.length };
323
+ const newDataset = datasetId !== currentDatasetId;
324
+ if (newDataset) { currentDatasetId = datasetId; scaleMax = 0; }
325
+ buildVectorField(data, newDataset);
326
+ return { count: data.count, u_var: data.u_var, v_var: data.v_var, depths: data.depth_levels.length, time: data.time };
259
327
  } catch (e) {
260
328
  console.error('3D preview failed:', e);
261
329
  throw e;
@@ -1,4 +1,4 @@
1
- """NECOFS parent-ocean delivery (schema z-v2): the vertical conversion, archive-file selection and
1
+ """NECOFS parent-ocean delivery (schema z-v3): the vertical conversion, archive-file selection and
2
2
  cache-key behaviour, on constructed data. No network.
3
3
 
4
4
  The conversion matters because the previous store labelled FVCOM sigma layers with fake depths in
@@ -113,7 +113,7 @@ def test_options_reach_only_fetchers_that_accept_them():
113
113
 
114
114
 
115
115
  def test_schema_tag_is_set():
116
- assert OBC_SCHEMA == "z-v2"
116
+ assert OBC_SCHEMA == "z-v3"
117
117
 
118
118
 
119
119
  def test_barycentric_weights_match_linear_nd_interpolation():
@@ -138,3 +138,45 @@ def test_barycentric_weights_match_linear_nd_interpolation():
138
138
  want = LinearNDInterpolator(tri, values[k])(targets).reshape(gx.shape)
139
139
  np.testing.assert_allclose(got[k], want, rtol=1e-12, atol=1e-12, equal_nan=True)
140
140
  assert np.isnan(got[0, 0, 0]) # (-0.1, -0.1) is outside the hull
141
+
142
+
143
+ def test_mesh_barycentric_leaves_land_between_elements_nan():
144
+ """Two triangles either side of a notch: a Delaunay triangulation of the four nodes would
145
+ bridge the notch, the mesh does not."""
146
+ from forcingkit.fetchers.necofs import MeshBarycentric
147
+
148
+ lon = np.array([0.0, 1.0, 0.0, 1.0, 0.5])
149
+ lat = np.array([0.0, 0.0, 1.0, 1.0, 0.2])
150
+ # Elements (0, 1, 4) along the bottom and (2, 4, 3) above leave the left and right gaps as land.
151
+ triangles = np.array([[0, 1, 4], [2, 4, 3]])
152
+ targets = np.array(
153
+ [
154
+ [0.5, 0.1], # inside the bottom element
155
+ [0.5, 0.6], # inside the top element
156
+ [0.1, 0.6], # land: inside the nodes' convex hull, in no element
157
+ [2.0, 2.0], # outside the mesh
158
+ ]
159
+ )
160
+ interp = MeshBarycentric(lon, lat, triangles, targets, (4,))
161
+ assert interp.inside.tolist() == [True, True, False, False]
162
+ # A linear field is reproduced exactly inside elements.
163
+ field = 2.0 * lon + 3.0 * lat + 1.0
164
+ out = interp(field)
165
+ expected = 2.0 * targets[:, 0] + 3.0 * targets[:, 1] + 1.0
166
+ np.testing.assert_allclose(out[:2], expected[:2], rtol=1e-12)
167
+ assert np.isnan(out[2:]).all()
168
+ # Leading dimensions (levels) pass through.
169
+ stacked = interp(np.vstack([field, 2 * field]))
170
+ np.testing.assert_allclose(stacked[1, :2], 2 * expected[:2], rtol=1e-12)
171
+
172
+
173
+ def test_restrict_masks_element_fields_on_land():
174
+ from scipy.spatial import Delaunay
175
+
176
+ from forcingkit.fetchers.necofs import Barycentric
177
+
178
+ pts = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
179
+ targets = np.array([[0.25, 0.25], [0.75, 0.75]])
180
+ interp = Barycentric(Delaunay(pts), targets, (2,)).restrict(np.array([True, False]))
181
+ out = interp(np.array([1.0, 1.0, 1.0, 1.0]))
182
+ assert out[0] == 1.0 and np.isnan(out[1])
@@ -0,0 +1,66 @@
1
+ """CO-OPS water level: NAVD88 where a station has it, MSL where it does not."""
2
+
3
+ from unittest import mock
4
+
5
+ from forcingkit.fetchers import noaa
6
+
7
+
8
+ class FakeResponse:
9
+ def __init__(self, payload, status=200):
10
+ self._payload = payload
11
+ self.status_code = status
12
+ self.content = b"x"
13
+
14
+ def json(self):
15
+ return self._payload
16
+
17
+ def raise_for_status(self):
18
+ if self.status_code >= 400:
19
+ raise RuntimeError(f"HTTP {self.status_code}")
20
+
21
+
22
+ def _fetch(tmp_path, responses):
23
+ calls = []
24
+
25
+ def fake_get(url, params, timeout):
26
+ calls.append(params["datum"])
27
+ return responses[params["datum"]]
28
+
29
+ with mock.patch.object(noaa.requests, "get", side_effect=fake_get):
30
+ data = noaa.fetch_noaa_tide_data(
31
+ "8465705",
32
+ "2026-04-02T00:00:00",
33
+ "2026-04-02T04:00:00",
34
+ cache_dir=str(tmp_path),
35
+ )
36
+ return data, calls
37
+
38
+
39
+ def test_falls_back_to_msl_when_navd_is_unsupported(tmp_path):
40
+ rows = {
41
+ "metadata": {"id": "8465705"},
42
+ "data": [{"t": "2026-04-02 00:00", "v": "0.1"}],
43
+ }
44
+ data, calls = _fetch(
45
+ tmp_path,
46
+ {
47
+ "NAVD": FakeResponse(
48
+ {
49
+ "error": {
50
+ "message": " The supported Datum values are: MHHW, MHW, MTL, MSL"
51
+ }
52
+ },
53
+ status=400,
54
+ ),
55
+ "MSL": FakeResponse(rows),
56
+ },
57
+ )
58
+ assert calls == ["NAVD", "MSL"]
59
+ assert data["metadata"]["datum"] == "MSL"
60
+
61
+
62
+ def test_uses_navd_when_available(tmp_path):
63
+ rows = {"metadata": {"id": "8516945"}, "data": []}
64
+ data, calls = _fetch(tmp_path, {"NAVD": FakeResponse(rows)})
65
+ assert calls == ["NAVD"]
66
+ assert data["metadata"]["datum"] == "NAVD"
@@ -873,7 +873,7 @@ wheels = [
873
873
 
874
874
  [[package]]
875
875
  name = "forcingkit"
876
- version = "0.1.0.post1"
876
+ version = "0.2.0"
877
877
  source = { editable = "." }
878
878
  dependencies = [
879
879
  { name = "bottleneck" },
File without changes
File without changes
File without changes
File without changes
File without changes