forcingkit 0.12.0__tar.gz → 0.12.1__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 (140) hide show
  1. {forcingkit-0.12.0 → forcingkit-0.12.1}/PKG-INFO +1 -1
  2. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/conf.py +1 -1
  3. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/necofs.rst +18 -2
  4. {forcingkit-0.12.0 → forcingkit-0.12.1}/pyproject.toml +1 -1
  5. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/necofs.py +145 -25
  6. forcingkit-0.12.1/tests/unit/test_necofs_mesh_cache.py +119 -0
  7. {forcingkit-0.12.0 → forcingkit-0.12.1}/uv.lock +1 -1
  8. {forcingkit-0.12.0 → forcingkit-0.12.1}/.agents/forcingkit.md +0 -0
  9. {forcingkit-0.12.0 → forcingkit-0.12.1}/.claude/CLAUDE.md +0 -0
  10. {forcingkit-0.12.0 → forcingkit-0.12.1}/.env.template +0 -0
  11. {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/copilot-instructions.md +0 -0
  12. {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/docs.yml +0 -0
  13. {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/publish.yml +0 -0
  14. {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/tests.yml +0 -0
  15. {forcingkit-0.12.0 → forcingkit-0.12.1}/.gitignore +0 -0
  16. {forcingkit-0.12.0 → forcingkit-0.12.1}/.markdownlint.yaml +0 -0
  17. {forcingkit-0.12.0 → forcingkit-0.12.1}/.pre-commit-config.yaml +0 -0
  18. {forcingkit-0.12.0 → forcingkit-0.12.1}/.python-version +0 -0
  19. {forcingkit-0.12.0 → forcingkit-0.12.1}/AGENTS.md +0 -0
  20. {forcingkit-0.12.0 → forcingkit-0.12.1}/CONTRIBUTING.md +0 -0
  21. {forcingkit-0.12.0 → forcingkit-0.12.1}/Dockerfile +0 -0
  22. {forcingkit-0.12.0 → forcingkit-0.12.1}/LICENSE +0 -0
  23. {forcingkit-0.12.0 → forcingkit-0.12.1}/README.md +0 -0
  24. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/README.md +0 -0
  25. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/pyproject.toml +0 -0
  26. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/__init__.py +0 -0
  27. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/client.py +0 -0
  28. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/schema.py +0 -0
  29. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/store.py +0 -0
  30. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/trajectory.py +0 -0
  31. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/conftest.py +0 -0
  32. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_client.py +0 -0
  33. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_schema.py +0 -0
  34. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_trajectory.py +0 -0
  35. {forcingkit-0.12.0 → forcingkit-0.12.1}/client/uv.lock +0 -0
  36. {forcingkit-0.12.0 → forcingkit-0.12.1}/docker-compose.yml +0 -0
  37. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/Makefile +0 -0
  38. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/make.bat +0 -0
  39. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/requirements-docs.txt +0 -0
  40. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_extra/CNAME +0 -0
  41. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_static/custom.css +0 -0
  42. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
  43. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/atmospheric_forcing.rst +0 -0
  44. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/client.rst +0 -0
  45. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/fetchers.rst +0 -0
  46. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/hycom.rst +0 -0
  47. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/index.rst +0 -0
  48. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/layered_mesh.rst +0 -0
  49. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/mesh_frames.rst +0 -0
  50. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/nyofs.rst +0 -0
  51. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/rivers.rst +0 -0
  52. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/roadmap.rst +0 -0
  53. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/sources.rst +0 -0
  54. {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/waves.rst +0 -0
  55. {forcingkit-0.12.0 → forcingkit-0.12.1}/main.py +0 -0
  56. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/__init__.py +0 -0
  57. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/__init__.py +0 -0
  58. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/jobs.py +0 -0
  59. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/main.py +0 -0
  60. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/bathymetry.py +0 -0
  61. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/plotly_api.py +0 -0
  62. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/removed.py +0 -0
  63. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/stores.py +0 -0
  64. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/viewer.py +0 -0
  65. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/stac.py +0 -0
  66. {forcingkit-0.12.0 → forcingkit-0.12.1}/service/run_server.py +0 -0
  67. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/__init__.py +0 -0
  68. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/data/rivers/northeast_us.json +0 -0
  69. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/dispatcher.py +0 -0
  70. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/coastwatch.py +0 -0
  71. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/dbofs.py +0 -0
  72. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/erddap.py +0 -0
  73. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr.py +0 -0
  74. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
  75. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_levels.py +0 -0
  76. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_points.py +0 -0
  77. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hycom.py +0 -0
  78. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hydrography.py +0 -0
  79. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/ndbc.py +0 -0
  80. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/noaa.py +0 -0
  81. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/nwps.py +0 -0
  82. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/nyofs.py +0 -0
  83. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/usgs.py +0 -0
  84. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
  85. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/layered_mesh.py +0 -0
  86. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/mesh_frames.py +0 -0
  87. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis.csv +0 -0
  88. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_dark.csv +0 -0
  89. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_deep.csv +0 -0
  90. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_haline.csv +0 -0
  91. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/render.py +0 -0
  92. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/settings.py +0 -0
  93. {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/zarr_stream.py +0 -0
  94. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/app.js +0 -0
  95. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/favicon.ico +0 -0
  96. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/favicon.svg +0 -0
  97. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/index.html +0 -0
  98. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/logo.svg +0 -0
  99. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/preview3d.js +0 -0
  100. {forcingkit-0.12.0 → forcingkit-0.12.1}/static/styles.css +0 -0
  101. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/__init__.py +0 -0
  102. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_auth.py +0 -0
  103. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_erddap_fetch.py +0 -0
  104. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_mesh_frames_necofs.py +0 -0
  105. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_necofs_volume_live.py +0 -0
  106. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_nwps_nomads.py +0 -0
  107. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_nyofs_obc_fetch.py +0 -0
  108. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_obc_offshore_nj.py +0 -0
  109. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_ofs_archive_paths.py +0 -0
  110. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_s3_roms_fetchers.py +0 -0
  111. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_dem.py +0 -0
  112. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_grib.py +0 -0
  113. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_zarr.py +0 -0
  114. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_coastwatch.py +0 -0
  115. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_dbofs.py +0 -0
  116. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_dispatcher.py +0 -0
  117. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_erddap.py +0 -0
  118. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_atm.py +0 -0
  119. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_idx.py +0 -0
  120. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_levels.py +0 -0
  121. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_points.py +0 -0
  122. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hycom.py +0 -0
  123. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_jobs.py +0 -0
  124. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_layered_mesh.py +0 -0
  125. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_main.py +0 -0
  126. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_mesh_frames.py +0 -0
  127. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_ndbc.py +0 -0
  128. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_necofs_parent.py +0 -0
  129. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_necofs_volume.py +0 -0
  130. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_noaa_currents.py +0 -0
  131. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_noaa_datum.py +0 -0
  132. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_nwps.py +0 -0
  133. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_nyofs.py +0 -0
  134. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_obc_donor_report.py +0 -0
  135. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_obc_pipeline.py +0 -0
  136. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_removed_routes.py +0 -0
  137. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_settings.py +0 -0
  138. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_usgs.py +0 -0
  139. {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_usgs_rivers.py +0 -0
  140. {forcingkit-0.12.0 → forcingkit-0.12.1}/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.12.0
3
+ Version: 0.12.1
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.12.0"
12
+ release = "0.12.1"
13
13
 
14
14
  extensions = [
15
15
  "sphinx.ext.autodoc",
@@ -316,6 +316,19 @@ window. Hyperslabs are fetched with ``FORCINGKIT_MAX_WORKERS`` threads. Even so,
316
316
  above (2,831 elements, 1,829 nodes, 45 layers, about 110 MB) took about five minutes from SMAST
317
317
  on 2026-10-22, almost all of it server round trips, and 0.1 s from the cache.
318
318
 
319
+ Finding a box's elements needs the whole mesh: the node and element coordinates (``lon``,
320
+ ``lat``, ``x``, ``y``, ``lonc``, ``latc``, ``xc``, ``yc``) and ``nv``, about 14 MB for GOM7,
321
+ with the edge-neighbour table built from them. ``read_surface``, ``read_volume`` and ``volume``
322
+ keep this once per mesh under ``$FORCINGKIT_CACHE_DIR/necofs/mesh/``, so an open of a remote file
323
+ reads only a few values from each end of ``nv``, ``lon`` and ``lonc`` (to check the mesh) and
324
+ then the requested data. The key is the file family (rolling forecast or daily archive), the node
325
+ and element counts and those end values: a new or renumbered mesh misses the cache, is read in
326
+ full and is logged as a warning. Entries are written under a temporary name and renamed, so
327
+ concurrent builds are safe, and an entry that cannot be read is read from the server again.
328
+ Results are the same values with or without the cache. Local files are not cached.
329
+
330
+ Every request to SMAST names the package in its User-Agent, as the other fetchers' requests do.
331
+
319
332
  Wetting and drying
320
333
  ~~~~~~~~~~~~~~~~~~
321
334
 
@@ -358,8 +371,11 @@ tag, and the passing of donor options through the dispatcher.
358
371
  every value lands on its own element, node, layer and hour; neighbours are mutual, and the
359
372
  subset's edge is never mistaken for the coast; boundary nodes; windows assembled across
360
373
  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.
374
+ cache. ``tests/unit/test_necofs_mesh_cache.py`` covers the mesh cache: the same subset from a
375
+ local file, a remote one read cold and one read from the cache; a renumbered mesh and the forecast
376
+ kept apart; a damaged entry read again; concurrent builds; and the User-Agent.
377
+ ``tests/integration/test_necofs_volume_live.py`` reads three hours across a file boundary from
378
+ SMAST.
363
379
 
364
380
  References
365
381
  ----------
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forcingkit"
7
- version = "0.12.0"
7
+ version = "0.12.1"
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"
@@ -1,9 +1,14 @@
1
1
  import warnings
2
2
  import logging
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import threading
7
+ from pathlib import Path
3
8
  import pandas as pd
4
9
  import numpy as np
5
10
  import xarray as xr
6
- from typing import Optional
11
+ from typing import Any, Optional
7
12
  from scipy.spatial import Delaunay
8
13
  from forcingkit import settings
9
14
 
@@ -47,7 +52,9 @@ def get_necofs_url(target_dt: pd.Timestamp) -> str:
47
52
  history_url = f"http://www.smast.umassd.edu:8080/thredds/dodsC/models/fvcom/NECOFS/Archive/necofs_history/NECOFS_GOM7_{date_str}.nc"
48
53
 
49
54
  try:
50
- resp = requests.get(history_url + ".dds", timeout=5)
55
+ resp = requests.get(
56
+ history_url + ".dds", headers={"User-Agent": USER_AGENT}, timeout=5
57
+ )
51
58
  if resp.status_code == 200:
52
59
  logger.info(f"Using historic NECOFS GOM7 archive: {history_url}")
53
60
  return history_url
@@ -294,7 +301,7 @@ def iter_parent(
294
301
  dap_url = get_necofs_url(file_date - pd.Timedelta(days=1))
295
302
  with warnings.catch_warnings():
296
303
  warnings.simplefilter("ignore")
297
- ds_raw = xr.open_dataset(dap_url, engine="pydap", decode_times=False)
304
+ ds_raw = _open_dap(dap_url)
298
305
  drop_vars = [v for v in ["Itime", "Itime2"] if v in ds_raw.variables]
299
306
  ds = xr.decode_cf(ds_raw.drop_vars(drop_vars))
300
307
  ds_times = pd.DatetimeIndex(ds.time.values)
@@ -446,23 +453,24 @@ def read_surface(
446
453
  url = dap_url or NECOFS_GOM7_URL
447
454
  with warnings.catch_warnings():
448
455
  warnings.simplefilter("ignore")
449
- ds_raw = xr.open_dataset(url, engine="pydap", decode_times=False)
456
+ ds_raw = _open_dap(url)
450
457
  drop_vars = [v for v in ["Itime", "Itime2"] if v in ds_raw.variables]
451
458
  ds = xr.decode_cf(ds_raw.drop_vars(drop_vars))
452
459
  times = pd.DatetimeIndex(ds.time.values)
453
460
  if times.tz is not None:
454
461
  times = times.tz_convert("UTC").tz_localize(None)
455
462
 
463
+ mesh = _whole_mesh(ds, url)
456
464
  min_lon, min_lat, max_lon, max_lat = bbox
457
- lonc = np.asarray(ds["lonc"].values, dtype=np.float64)
458
- latc = np.asarray(ds["latc"].values, dtype=np.float64)
465
+ lonc = np.asarray(mesh["lonc"], dtype=np.float64)
466
+ latc = np.asarray(mesh["latc"], dtype=np.float64)
459
467
  elems = np.flatnonzero(
460
468
  (lonc >= min_lon) & (lonc <= max_lon) & (latc >= min_lat) & (latc <= max_lat)
461
469
  )
462
470
  if elems.size == 0:
463
471
  raise ValueError(f"No NECOFS elements inside {bbox}")
464
472
  nv = (
465
- np.asarray(ds["nv"].values).T.astype(np.int64) - 1
473
+ np.asarray(mesh["nv"]).T.astype(np.int64) - 1
466
474
  ) # (element, 3), zero-based node indices
467
475
  tri_global = nv[elems]
468
476
  nodes, tri_local = np.unique(tri_global, return_inverse=True)
@@ -512,11 +520,11 @@ def read_surface(
512
520
  "triangles": (("element", "vertex"), tri_local),
513
521
  "node_lon": (
514
522
  ("node",),
515
- np.asarray(ds["lon"].values)[nodes].astype(np.float32),
523
+ np.asarray(mesh["lon"])[nodes].astype(np.float32),
516
524
  ),
517
525
  "node_lat": (
518
526
  ("node",),
519
- np.asarray(ds["lat"].values)[nodes].astype(np.float32),
527
+ np.asarray(mesh["lat"])[nodes].astype(np.float32),
520
528
  ),
521
529
  "element_id": (("element",), elems.astype(np.int64)),
522
530
  },
@@ -538,11 +546,131 @@ NO_NEIGHBOUR = -1 # the edge is on the mesh boundary (coast or open boundary)
538
546
  OUTSIDE_SUBSET = -2 # the neighbour exists but lies outside the subset
539
547
 
540
548
 
549
+ # Every request to the model's server names this package, as the other fetchers' requests do.
550
+ USER_AGENT = "forcingkit (+https://github.com/lhzn-io/forcingkit)"
551
+ _SESSION = None
552
+ _SESSION_LOCK = threading.Lock()
553
+
554
+
555
+ def _open_dap(url: str) -> xr.Dataset:
556
+ """`url` opened lazily with xarray's pydap engine (times not decoded); a remote file through one requests
557
+ session that sends USER_AGENT."""
558
+ global _SESSION
559
+ kwargs = {}
560
+ if str(url).startswith(("http://", "https://")):
561
+ import requests
562
+
563
+ with _SESSION_LOCK:
564
+ if _SESSION is None:
565
+ _SESSION = requests.Session()
566
+ _SESSION.headers["User-Agent"] = USER_AGENT
567
+ kwargs["session"] = _SESSION
568
+ return xr.open_dataset(url, engine="pydap", decode_times=False, **kwargs)
569
+
570
+
571
+ # The model's whole mesh (node and element coordinates and the triangles, about 14 MB for GOM7) is read on every open
572
+ # to find a box's elements. It does not change between the files of one mesh, so for a remote file it is kept on disk
573
+ # under settings.cache_dir("necofs", "mesh"), keyed by the file family (rolling forecast or daily archive), the node
574
+ # and element counts and the first and last few values of nv, lon and lonc; a changed or renumbered mesh misses the
575
+ # cache and is read again, with a warning. Results are the same values either way.
576
+ MESH_SCHEMA = "gom7-mesh-v1"
577
+ MESH_VARIABLES = ("lon", "lat", "x", "y", "lonc", "latc", "xc", "yc", "nv")
578
+ _MESH_PROBE = 8 # values read from each end of nv, lon and lonc for the key
579
+ _MESHES: dict[str, dict[str, np.ndarray]] = {}
580
+ _MESH_LOCK = threading.Lock()
581
+
582
+
583
+ def _mesh_key(ds: xr.Dataset, url: str) -> tuple[str, str]:
584
+ """(family, key) for a remote file's mesh: a few small reads, no whole arrays."""
585
+ family = "forecast" if url == NECOFS_GOM7_URL else "archive"
586
+ n = _MESH_PROBE
587
+ probe = {
588
+ "schema": MESH_SCHEMA,
589
+ "family": family,
590
+ "node": int(ds.sizes["node"]),
591
+ "nele": int(ds.sizes["nele"]),
592
+ }
593
+ for name, dim in (("nv", "nele"), ("lon", "node"), ("lonc", "nele")):
594
+ size = int(ds.sizes[dim])
595
+ head = np.asarray(ds[name].isel({dim: slice(0, n)}).values)
596
+ tail = np.asarray(ds[name].isel({dim: slice(size - n, size)}).values)
597
+ probe[name] = [head.tolist(), tail.tolist()]
598
+ digest = hashlib.sha256(json.dumps(probe, sort_keys=True).encode()).hexdigest()[:16]
599
+ return family, f"{family}-{digest}"
600
+
601
+
602
+ def _derive_mesh(arrays: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
603
+ """The whole mesh's edge neighbours and boundary nodes, from its triangles."""
604
+ from forcingkit.mesh_frames import element_neighbours
605
+
606
+ nv = np.asarray(arrays["nv"]).T.astype(np.int64) - 1 # (element, 3), zero-based
607
+ nbe_all = element_neighbours(nv)
608
+ open_edge = nbe_all < 0
609
+ boundary_nodes = np.unique(
610
+ np.concatenate(
611
+ [nv[open_edge[:, j], (j + 1) % 3] for j in range(3)]
612
+ + [nv[open_edge[:, j], (j + 2) % 3] for j in range(3)]
613
+ )
614
+ )
615
+ return {**arrays, "nbe_all": nbe_all, "boundary_nodes": boundary_nodes}
616
+
617
+
618
+ def _whole_mesh(ds: xr.Dataset, url: str) -> dict[str, np.ndarray]:
619
+ """The file's MESH_VARIABLES as stored, with `nbe_all` and `boundary_nodes`; for a remote file from the cache when
620
+ its mesh has been read before. Safe from several threads or processes: an entry is written under a temporary name
621
+ and renamed, and an entry that cannot be read is read from the server again."""
622
+
623
+ def read() -> dict[str, np.ndarray]:
624
+ return _derive_mesh(
625
+ {name: np.asarray(ds[name].values) for name in MESH_VARIABLES}
626
+ )
627
+
628
+ if not str(url).startswith(("http://", "https://")):
629
+ return read()
630
+ family, key = _mesh_key(ds, url)
631
+ with _MESH_LOCK:
632
+ if key in _MESHES:
633
+ return _MESHES[key]
634
+ path = Path(settings.cache_dir("necofs", "mesh", f"{key}.npz"))
635
+ mesh = None
636
+ if path.is_file():
637
+ try:
638
+ with np.load(path) as z:
639
+ mesh = {name: z[name] for name in z.files}
640
+ except Exception as exc: # a damaged entry: read the mesh again
641
+ logger.warning(
642
+ f"NECOFS mesh cache {path} not read ({exc}); reading the mesh from {url}"
643
+ )
644
+ if mesh is None:
645
+ others = (
646
+ sorted(p.name for p in path.parent.glob(f"{family}-*.npz"))
647
+ if path.parent.is_dir()
648
+ else []
649
+ )
650
+ if others:
651
+ logger.warning(
652
+ f"NECOFS {family} mesh differs from the cached {others}; reading the new mesh from {url}"
653
+ )
654
+ mesh = read()
655
+ path.parent.mkdir(parents=True, exist_ok=True)
656
+ tmp = path.with_name(
657
+ f".{path.stem}.{os.getpid()}.{threading.get_ident()}.tmp.npz"
658
+ )
659
+ # As Any: numpy's stubs would read the keyword arrays as its own options.
660
+ arrays: dict[str, Any] = mesh
661
+ np.savez(tmp, **arrays)
662
+ os.replace(tmp, path)
663
+ logger.info(f"NECOFS mesh read from {url}, cached as {path}")
664
+ with _MESH_LOCK:
665
+ _MESHES[key] = mesh
666
+ return mesh
667
+
668
+
541
669
  def _open_archive(url: str) -> tuple[xr.Dataset, pd.DatetimeIndex]:
542
670
  """An FVCOM file opened over OPeNDAP with CF times decoded, and its times as naive UTC."""
543
671
  with warnings.catch_warnings():
544
672
  warnings.simplefilter("ignore")
545
- ds_raw = xr.open_dataset(url, engine="pydap", decode_times=False)
673
+ ds_raw = _open_dap(url)
546
674
  drop_vars = [v for v in ["Itime", "Itime2"] if v in ds_raw.variables]
547
675
  ds = xr.decode_cf(ds_raw.drop_vars(drop_vars))
548
676
  times = pd.DatetimeIndex(ds.time.values)
@@ -622,39 +750,31 @@ def _subset_volume(
622
750
  ds: xr.Dataset, times: pd.DatetimeIndex, bbox, wanted: pd.DatetimeIndex, url: str
623
751
  ) -> xr.Dataset:
624
752
  """The full-depth subset of an opened FVCOM file (see `read_volume`)."""
625
- from forcingkit.mesh_frames import element_neighbours
626
-
753
+ mesh = _whole_mesh(ds, url)
627
754
  min_lon, min_lat, max_lon, max_lat = bbox
628
- lonc = np.asarray(ds["lonc"].values, dtype=np.float64)
629
- latc = np.asarray(ds["latc"].values, dtype=np.float64)
755
+ lonc = np.asarray(mesh["lonc"], dtype=np.float64)
756
+ latc = np.asarray(mesh["latc"], dtype=np.float64)
630
757
  elems = np.flatnonzero(
631
758
  (lonc >= min_lon) & (lonc <= max_lon) & (latc >= min_lat) & (latc <= max_lat)
632
759
  )
633
760
  if elems.size == 0:
634
761
  raise ValueError(f"No NECOFS elements inside {bbox}")
635
762
 
636
- nv = np.asarray(ds["nv"].values).T.astype(np.int64) - 1 # (element, 3), zero-based
763
+ nv = np.asarray(mesh["nv"]).T.astype(np.int64) - 1 # (element, 3), zero-based
637
764
  tri_global = nv[elems]
638
765
  nodes, tri_local = np.unique(tri_global, return_inverse=True)
639
766
  tri_local = tri_local.reshape(tri_global.shape).astype(np.int32)
640
767
 
641
768
  # Neighbours and boundary nodes come from the whole mesh: on the subset alone every element
642
769
  # on the subset's edge would look like a boundary element.
643
- nbe_all = element_neighbours(nv)
770
+ nbe_all = mesh["nbe_all"]
644
771
  nbe = nbe_all[elems]
645
772
  pos = np.clip(np.searchsorted(elems, np.where(nbe >= 0, nbe, 0)), 0, elems.size - 1)
646
773
  selected = (nbe >= 0) & (elems[pos] == nbe)
647
774
  neighbours = np.where(
648
775
  nbe < 0, NO_NEIGHBOUR, np.where(selected, pos, OUTSIDE_SUBSET)
649
776
  ).astype(np.int32)
650
- open_edge = nbe_all < 0
651
- boundary_nodes = np.unique(
652
- np.concatenate(
653
- [nv[open_edge[:, j], (j + 1) % 3] for j in range(3)]
654
- + [nv[open_edge[:, j], (j + 2) % 3] for j in range(3)]
655
- )
656
- )
657
- on_boundary = np.isin(nodes, boundary_nodes)
777
+ on_boundary = np.isin(nodes, mesh["boundary_nodes"])
658
778
 
659
779
  t_idx = times.get_indexer(wanted)
660
780
  keep = t_idx >= 0
@@ -676,7 +796,7 @@ def _subset_volume(
676
796
  return _read_columns(ds[name], "node", nodes, None)
677
797
 
678
798
  def coord(name, idx):
679
- return np.asarray(ds[name].values, dtype=np.float64)[idx]
799
+ return np.asarray(mesh[name], dtype=np.float64)[idx]
680
800
 
681
801
  lay, lev, ele, nod = "siglay", "siglev", "element", "node"
682
802
  data = {
@@ -0,0 +1,119 @@
1
+ """The NECOFS whole-mesh cache and the server session, on the synthetic FVCOM-shaped file of test_necofs_volume. No
2
+ network: a remote URL only selects the cached path."""
3
+
4
+ import concurrent.futures
5
+ import logging
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import pytest
10
+ import xarray as xr
11
+
12
+ from forcingkit.fetchers import necofs
13
+ from test_necofs_volume import INNER, fvcom_file
14
+
15
+ REMOTE = "http://example.invalid/thredds/dodsC/NECOFS_GOM7_2026_04_02.nc"
16
+ TIMES = pd.date_range("2026-04-01T01:00", periods=3, freq="1h")
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def cache(monkeypatch, tmp_path):
21
+ monkeypatch.setenv("FORCINGKIT_CACHE_DIR", str(tmp_path))
22
+ monkeypatch.setattr(necofs, "_MESHES", {})
23
+ return tmp_path / "necofs" / "mesh"
24
+
25
+
26
+ def subset(ds, url):
27
+ return necofs._subset_volume(ds, TIMES, INNER, TIMES, url)
28
+
29
+
30
+ def test_a_remote_subset_equals_a_local_one_cold_and_from_the_cache(cache):
31
+ ds = fvcom_file(TIMES)
32
+ local = subset(ds, "file://local")
33
+ cold = subset(ds, REMOTE)
34
+ necofs._MESHES.clear() # a new process: only the file on disk
35
+ warm = subset(ds, REMOTE)
36
+ for out in (cold, warm):
37
+ for name in local.variables:
38
+ np.testing.assert_array_equal(out[name].values, local[name].values)
39
+ assert out[name].dtype == local[name].dtype
40
+ assert len(list(cache.glob("archive-*.npz"))) == 1
41
+
42
+
43
+ def test_the_cache_is_used_instead_of_the_file(cache):
44
+ ds = fvcom_file(TIMES)
45
+ first = subset(ds, REMOTE)
46
+ necofs._MESHES.clear()
47
+ # The same key (nv, lon, lonc and the counts), a different easting.
48
+ moved = ds.assign(x=ds["x"] + 1.0)
49
+ np.testing.assert_array_equal(subset(moved, REMOTE)["x"].values, first["x"].values)
50
+
51
+
52
+ def test_local_files_are_never_cached(cache):
53
+ subset(fvcom_file(TIMES), "file://local")
54
+ assert not cache.exists()
55
+
56
+
57
+ def test_a_renumbered_mesh_misses_the_cache_with_a_warning(cache, caplog):
58
+ ds = fvcom_file(TIMES)
59
+ subset(ds, REMOTE)
60
+ nv = ds["nv"].values.copy()
61
+ nv[:, [0, 1]] = nv[:, [1, 0]] # two elements swapped: same counts and extents
62
+ renumbered = ds.assign(nv=(ds["nv"].dims, nv))
63
+ with caplog.at_level(logging.WARNING, logger=necofs.logger.name):
64
+ subset(renumbered, REMOTE)
65
+ assert len(list(cache.glob("archive-*.npz"))) == 2
66
+ assert "differs from the cached" in caplog.text
67
+
68
+
69
+ def test_the_forecast_and_the_archive_are_kept_apart(cache):
70
+ ds = fvcom_file(TIMES)
71
+ subset(ds, REMOTE)
72
+ subset(ds, necofs.NECOFS_GOM7_URL)
73
+ assert (
74
+ len(list(cache.glob("archive-*.npz"))) == 1
75
+ and len(list(cache.glob("forecast-*.npz"))) == 1
76
+ )
77
+
78
+
79
+ def test_a_damaged_entry_is_read_again(cache, caplog):
80
+ ds = fvcom_file(TIMES)
81
+ subset(ds, REMOTE)
82
+ (entry,) = cache.glob("archive-*.npz")
83
+ entry.write_bytes(b"not an npz")
84
+ necofs._MESHES.clear()
85
+ with caplog.at_level(logging.WARNING, logger=necofs.logger.name):
86
+ out = subset(ds, REMOTE)
87
+ np.testing.assert_array_equal(
88
+ out["u"].values, subset(ds, "file://local")["u"].values
89
+ )
90
+ assert "not read" in caplog.text
91
+ with np.load(entry) as z: # rewritten whole
92
+ assert "nbe_all" in z.files
93
+
94
+
95
+ def test_concurrent_builds_leave_one_complete_entry(cache):
96
+ ds = fvcom_file(TIMES)
97
+ with concurrent.futures.ThreadPoolExecutor(4) as pool:
98
+ outs = list(pool.map(lambda _: subset(ds, REMOTE), range(8)))
99
+ for out in outs[1:]:
100
+ np.testing.assert_array_equal(
101
+ out["neighbours"].values, outs[0]["neighbours"].values
102
+ )
103
+ assert len(list(cache.glob("archive-*.npz"))) == 1
104
+ assert not list(cache.glob(".*.tmp.npz"))
105
+
106
+
107
+ def test_remote_opens_send_the_user_agent(monkeypatch):
108
+ seen = {}
109
+
110
+ def fake_open(url, **kwargs):
111
+ seen[url] = kwargs
112
+ return xr.Dataset()
113
+
114
+ monkeypatch.setattr(necofs.xr, "open_dataset", fake_open)
115
+ monkeypatch.setattr(necofs, "_SESSION", None)
116
+ necofs._open_dap(REMOTE)
117
+ necofs._open_dap("/data/local.nc")
118
+ assert seen[REMOTE]["session"].headers["User-Agent"] == necofs.USER_AGENT
119
+ assert "session" not in seen["/data/local.nc"]
@@ -1000,7 +1000,7 @@ wheels = [
1000
1000
 
1001
1001
  [[package]]
1002
1002
  name = "forcingkit"
1003
- version = "0.12.0"
1003
+ version = "0.12.1"
1004
1004
  source = { editable = "." }
1005
1005
  dependencies = [
1006
1006
  { name = "bottleneck" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes