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.
- {forcingkit-0.12.0 → forcingkit-0.12.1}/PKG-INFO +1 -1
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/conf.py +1 -1
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/necofs.rst +18 -2
- {forcingkit-0.12.0 → forcingkit-0.12.1}/pyproject.toml +1 -1
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/necofs.py +145 -25
- forcingkit-0.12.1/tests/unit/test_necofs_mesh_cache.py +119 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/uv.lock +1 -1
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.agents/forcingkit.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.claude/CLAUDE.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.env.template +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/copilot-instructions.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/docs.yml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/publish.yml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.github/workflows/tests.yml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.gitignore +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.markdownlint.yaml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.pre-commit-config.yaml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/.python-version +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/AGENTS.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/CONTRIBUTING.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/Dockerfile +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/LICENSE +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/README.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/README.md +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/pyproject.toml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/__init__.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/client.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/schema.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/store.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/src/forcingkit_client/trajectory.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/conftest.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_client.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_schema.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/tests/test_trajectory.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/client/uv.lock +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docker-compose.yml +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/Makefile +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/make.bat +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/requirements-docs.txt +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_extra/CNAME +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_static/custom.css +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/_static/forcingkit-noreaster-wind.gif +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/atmospheric_forcing.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/client.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/fetchers.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/hycom.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/index.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/layered_mesh.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/mesh_frames.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/nyofs.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/rivers.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/roadmap.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/sources.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/docs/source/waves.rst +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/main.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/__init__.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/__init__.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/jobs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/main.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/bathymetry.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/plotly_api.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/removed.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/stores.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/routers/viewer.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/forcingkit_serve/stac.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/service/run_server.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/__init__.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/data/rivers/northeast_us.json +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/dispatcher.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/coastwatch.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/dbofs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/erddap.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_atmosphere.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_levels.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hrrr_points.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hycom.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/hydrography.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/ndbc.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/noaa.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/nwps.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/nyofs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/usgs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/fetchers/usgs_rivers.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/layered_mesh.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/mesh_frames.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis.csv +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_dark.csv +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_deep.csv +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/ramps/currents_lis_haline.csv +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/render.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/settings.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/src/forcingkit/zarr_stream.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/app.js +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/favicon.ico +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/favicon.svg +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/index.html +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/logo.svg +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/preview3d.js +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/static/styles.css +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/__init__.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_auth.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_erddap_fetch.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_mesh_frames_necofs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_necofs_volume_live.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_nwps_nomads.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_nyofs_obc_fetch.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_obc_offshore_nj.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_ofs_archive_paths.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/integration/test_s3_roms_fetchers.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_dem.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_grib.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/scripts/inspect_zarr.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_coastwatch.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_dbofs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_dispatcher.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_erddap.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_atm.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_idx.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_levels.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hrrr_points.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_hycom.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_jobs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_layered_mesh.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_main.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_mesh_frames.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_ndbc.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_necofs_parent.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_necofs_volume.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_noaa_currents.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_noaa_datum.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_nwps.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_nyofs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_obc_donor_report.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_obc_pipeline.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_removed_routes.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_settings.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_usgs.py +0 -0
- {forcingkit-0.12.0 → forcingkit-0.12.1}/tests/unit/test_usgs_rivers.py +0 -0
- {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.
|
|
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
|
|
@@ -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/
|
|
362
|
-
|
|
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.
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
458
|
-
latc = np.asarray(
|
|
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(
|
|
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(
|
|
523
|
+
np.asarray(mesh["lon"])[nodes].astype(np.float32),
|
|
516
524
|
),
|
|
517
525
|
"node_lat": (
|
|
518
526
|
("node",),
|
|
519
|
-
np.asarray(
|
|
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 =
|
|
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
|
-
|
|
626
|
-
|
|
753
|
+
mesh = _whole_mesh(ds, url)
|
|
627
754
|
min_lon, min_lat, max_lon, max_lat = bbox
|
|
628
|
-
lonc = np.asarray(
|
|
629
|
-
latc = np.asarray(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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"]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|