Python client library for loading and sampling Amphitrite's NetCDF datasets.
Project description
amphi-ncdata
amphi-ncdata is a Python client library for loading and sampling Amphi NetCDF datasets.
It exposes these main entry points:
AmphiNcData: the easiest high-level API for clients using Amphi-style dataset folders.MultiAmphiNcData: anAmphiNcData-style env spanning several bounding boxes, routing each query to the owning box in O(1).NcData: a lower-level API for loading arbitrary NetCDF files or basepaths.
This README is for clients using an already built wheel. Repository maintainers should use README_DEV.md.
All timestamps in the public API are Unix timestamps in seconds.
Install From The Wheel
Ask for the wheel that matches:
- your operating system
- your Python version
- your machine architecture
Install it with:
python3 -m pip install /path/to/amphi_ncdata-0.1.0-<platform>.whl
Quick import check:
python3 -c "import amphi_ncdata; print(amphi_ncdata.__version__)"
If installation succeeds but import fails with a missing shared-library error, ask us for a wheel built for your environment or install the required NetCDF runtime libraries for your platform.
Quick Start
The fastest way to use the library is with AmphiNcData.
Example script:
from amphi_ncdata import AmphiNcData
env = AmphiNcData(
{
"currentsBasepaths": ["/data/data_api/FORECAST-CURRENT/"],
"windBasepaths": ["/data/data_api/FORECAST/wind/"],
"waveBasepaths": ["/data/data_api/FORECAST/wave/"],
"climWindBasepaths": ["/data/data_api/HINDCAST-3Y/"],
"climWaveBasepaths": ["/data/data_api/HINDCAST-3Y-WAVE/"],
},
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
)
lat, lon = 40.0, -50.0
timestamp = 1781524800
currents = env.currents()
wind = env.wind()
waves = env.waves()
currents.set_interpolation_method("linear")
wind.set_interpolation_method("linear")
waves.set_interpolation_method("linear")
u_current, v_current = currents.at(lat, lon, timestamp)
u_wind, v_wind = wind.at(lat, lon, timestamp)
wave_height, wave_period, wave_direction = waves.at(lat, lon, timestamp)
print("Currents:", u_current, v_current)
print("Wind:", u_wind, v_wind)
print("Waves:", wave_height, wave_period, wave_direction)
A ready-to-copy version also lives in examples/client_quickstart.py.
Main Concepts
AmphiNcDatais the convenience layer for Amphi folder layouts, masks, and tides.NcDatais the generic layer when you want to choose the files or variables yourself.load()registers which variables you want available.set_interpolation_method()sets the dataset interpolation mode once.set_out_of_window_behaviour(behaviour, value, dim=...)sets what a query outside the loaded window returns, per dimension (dim="time"/"space"/"both"):"nearest","nan","value", or"fallback"(anotherNcData). Defaults: time→nearest, space→nan.sample()returns one variable.at()returns all loaded variables for the dataset.at()/sample()are fast for sequential, point-by-point access (e.g. routing);sample_batch()/at_batch()are best when you already have many independent points to sample at once.env.wind()/env.waves()auto-fall-back to climatology past the forecast horizon (when climatology basepaths are set);env.climWind()/env.climWave()are the underlying climatology datasets.
Using AmphiNcData
AmphiNcData is the recommended API for most client code.
Using an input.json-style config
from amphi_ncdata import AmphiNcData
import json
with open("input.json", "r", encoding="utf-8") as f:
cfg = json.load(f)
env = AmphiNcData(
cfg,
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
)
Using a flat config
env = AmphiNcData(
{
"currentsBasepaths": ["/data/data_api/FORECAST-CURRENT/"],
"tidesBasepaths": ["/data/data_api/FORECAST-TIDE/"],
"windBasepaths": ["/data/data_api/FORECAST/wind/"],
"waveBasepaths": ["/data/data_api/FORECAST/wave/"],
"climWindBasepaths": ["/data/data_api/HINDCAST-3Y/"],
"climWaveBasepaths": ["/data/data_api/HINDCAST-3Y-WAVE/"],
"bathymetryPath": "/data/data_save/ETOPO/bathy.nc",
"tidesMaskPath": "/data/data_api/FORECAST-TIDE/masks_tide.nc",
"nogoPath": "/data/data_user/nogo.nc",
"coastalZonePath": "/data/data_save/coastal_zone_mask.nc",
},
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
)
High-level helpers
currents = env.currents()
wind = env.wind()
waves = env.waves()
tidal = env.tidal_currents()
bathy = env.bathymetry()
nogo = env.nogo()
coastal = env.coastal_zone()
tides_mask = env.tides_mask()
Return shapes:
env.currents().at(...)->(ucos, vcos)env.tidal_currents().at(...)->(utotal, vtotal)env.wind().at(...)->(u10, v10)env.waves().at(...)->(swh, mwp, mwd)env.bathymetry().at(...)->bathymetryenv.nogo().at(...)->maskenv.coastal_zone().at(...)->maskenv.tides_mask().at(...)->mask_TIDE
Tidal currents: the fused tidal_currents()
Tides only matter near coasts (the global tide mask is ~9% active), so
env.tidal_currents() does not load the whole bounding box. With a
tidesMaskPath configured it loads eager 1-degree masked tiles: every
1-degree tile that the mask marks active is pulled into RAM up front, in the raw
NetCDF dtype, so the routing hot path never touches NetCDF I/O. Open-ocean and
land tiles allocate nothing.
tidal_currents() is fused: at() returns the hourly tidal current where
the mask is active (coastal) and the open-ocean current where it is not — so a
single call gives the right water velocity everywhere, no manual branch:
from datetime import datetime, timezone
from amphi_ncdata import AmphiNcData
start = int(datetime(2026, 6, 23, tzinfo=timezone.utc).timestamp())
env = AmphiNcData(
{
"tidesBasepaths": ["/data/data_api/FORECAST-TIDE/"],
# The mask is what turns tides into eager 1-degree masked tiles:
"tidesMaskPath": "/data/data_api/FORECAST-TIDE/masks_tide.nc",
# "tidesTileSizeDegrees": 1.0, # optional; 1.0 is the default
},
start=start, end=start + 24 * 3600,
south=48.0, west=-6.0, north=52.5, east=4.5, # English Channel
)
tides = env.tidal_currents() # eager 1° masked tiles + a daily open-ocean step
u, v = tides.at(lat, lon, timestamp) # tide near the coast, current offshore — one call
How the open ocean is served. Where the tide product is not tidal (mask 0),
its values are constant over the day and equal the surface currents (verified:
p99 = 0, mean diff 3.7e-5 m/s over thousands of open-ocean points). So
tidal_currents() loads one daily tide step over the full bbox as the
open-ocean field — self-contained (no separate currents product needed), same
variable names, and roughly half the RAM of loading the multi-step currents
product. Coastal cells are overridden by the hourly tide tiles.
You normally never call the gate yourself, but it is exposed for inspection and
for fused=False:
tides.mask(lat, lon) # compiled gate (~0.2 us): is this point tidal?
tides.mask_batch(lats, lons) # vectorised
mask() is the nearest high-res mask_TIDE cell (active = non-zero); it is
spatial, so timestamp is accepted for symmetry but ignored. A masked-in cell
can still be land / fill (NaN) — mask() selects the data source, not its
presence.
Knobs: tidesTileSizeDegrees (default 1.0) sets the tile size;
TidalCurrents(..., eager=False) keeps tiles lazy with an LRU cache (smaller
resident footprint, per-call I/O); TidalCurrents(..., fused=False) makes at()
tide-only again (NaN in the open ocean) if you want to gate manually.
What if you omit the tide paths?
| configuration | env.tidal_currents() behaviour |
|---|---|
tidesBasepaths and tidesMaskPath set |
Eager 1° masked tiles + fused open ocean (the recommended setup). at() is tide-where-coastal, current-where-not; mask() reflects the high-res tide mask. |
tidesBasepaths set, tidesMaskPath omitted |
Falls back to the full-bbox tidal view (lazy, no tiling, no fusion). at() samples tides everywhere in the box, and mask() returns True for any in-box point (no mask to gate on). Memory is the full bbox × all tide steps — fine for a small box, large for a wide one. |
tidesBasepaths omitted |
env.tidal_currents() raises AmphiDiscoveryError ("No valid NetCDF files were found …"). Only call it when tides are configured; currents, wind, and waves are independent and still work — env.currents() stays the currents-only, memory-light path. |
Out-of-window behaviour (per dimension, with climatology fallback)
A query can fall outside the loaded window in time (past/before the forecast) or in space (outside the bounding box). Each is handled independently:
wind.set_out_of_window_behaviour(behaviour, value=0.0, dim="both")
dim:"time","space", or"both"(default).behaviour:"nearest"— clamp to the nearest timestep / edge cell."nan"— return NaN."value"— return the constantvalue(a float)."fallback"(alias"data") — samplevalueinstead, which must be anotherNcDatathat covers the query (e.g. a climatology). The fallback dataset carries its own out-of-window behaviour.
- Defaults: time →
nearest, space →nan.
Precedence when out of window: a fallback dimension delegates the whole sample
to its dataset (time is checked before space); otherwise a nan/value on space
applies before time; nearest simply clamps that dimension. This is independent
of NaN coming from the data itself (land / fill), which is always NaN.
(set_out_of_window_behaviour("nan") / ("value", -999) still set both
dimensions, for backward compatibility.)
Climatology fallback (automatic for wind / waves)
env.wind() and env.waves() automatically fall back to climatology past the
forecast horizon when climatology basepaths are configured — equivalent to:
wind.set_out_of_window_behaviour("fallback", env.climWind(), dim="time")
So a long voyage just keeps sampling, with no manual gap handling:
wind = env.wind() # forecast + climatology time-fallback
u, v = wind.at(40.0, -50.0, ts) # forecast in-window, climatology past the horizon
The climatology (env.climWind() -> (u10, v10), env.climWave() ->
(swh, mwp, mwd)) is still its own dataset and is configured as the safety net:
nearest in both time and space and linear interpolation, so it always returns
a value. Pass no climatology basepaths, or call
wind.set_out_of_window_behaviour("nan", dim="time"), to opt out (e.g. to detect
the gap yourself).
load_defaults()
env.load_defaults(
include_tides=True,
include_bathymetry=True,
include_nogo=True,
include_coastal_zone=True,
)
This preloads:
- currents
- wind
- waves
- optionally tidal currents and tides mask (tides eager-load their active 1° tiles up front — fast for a route corridor, heavier for a continental box; see the gate section)
- optionally bathymetry
- optionally no-go mask
- optionally coastal zone mask
Overriding default variable names
If your files use different names:
custom_currents = env.currents(u_var="u_current", v_var="v_current")
custom_wind = env.wind(u_var="u_wind", v_var="v_wind")
custom_waves = env.waves(height_var="hs", period_var="tp", direction_var="dir")
Multiple bounding boxes: MultiAmphiNcData
Use MultiAmphiNcData when one env should cover several bounding boxes /
time windows at once — e.g. tiling a large region, or layering a smaller box on
top of a bigger one. All boxes share the same config; each carries its own
start, end, south, west, north, east. Every accessor mirrors AmphiNcData
(currents(), wind(), waves(), tidal_currents(), bathymetry(), …) but
returns a routed dataset whose at/at_batch/sample/sample_batch dispatch
each point to the box that owns it.
from amphi_ncdata import MultiAmphiNcData # or AmphiNcData.from_bounding_boxes(...)
env = MultiAmphiNcData(
{"currentsBasepaths": ["/data/data_api/FORECAST-CURRENT/"]}, # shared by all boxes
boxes=[
dict(start=1781478000, end=1781650800, south=35.0, west=-73.0, north=43.0, east=-30.0),
dict(start=1781478000, end=1781650800, south=43.0, west=-30.0, north=60.0, east=10.0),
],
# out_of_box_value=float("nan"), # what a point in no box returns (default NaN)
)
currents = env.currents()
currents.set_interpolation_method("linear")
u, v = currents.at(40.0, -50.0, 1781524800) # routed to the owning box, O(1)
uv = currents.at_batch(lats, lons, timestamps) # bucketed per box, vectorized
How dispatch works:
- A dense
(lat, lon, hour)lookup grid (1° × 1° × 1 h) stores, per cell, the index of the winning box. A query computes three integer indices and reads one cell — the heavy bilinear + time interpolation still runs in each box's compiled sampler. - Overlap → first listed box wins. When boxes overlap in both space and
time, the earliest box in
boxesthat covers the point is chosen (so you can layer a high-res box on top of a coarse one by listing it first). - A point in no box returns
out_of_box_value(defaultNaN). - If every box shares the same
[start, end], the time axis collapses and the grid stays tiny (~130 KB); otherwise it grows with the total time span (~22 MB for a 7-day window). - Boundary note: at 1° resolution, a box with non-integer degree edges shares its edge cell with neighbours; a point in that cell but just outside its assigned box falls to that box's own out-of-window behaviour. This is exact for integer-degree boxes (as in the examples above).
env.tidal_currents(), env.wind(), etc. work the same way and keep their
per-box behaviour (tide tiling, climatology fallback). MultiAmphiNcData is a
context manager and forwards close() to every box's env.
Using NcData
Use NcData when you want more manual control.
Load from basepaths
Use this when the library should discover the right NetCDF files for you.
from amphi_ncdata import NcData
currents = NcData.from_basepaths(
basepaths=[
"/data/data_api/FORECAST-CURRENT/",
"/mnt/store/data_api/HIRES_v3/FORECAST/",
],
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
max_forecast_days=7,
allow_last_available=True,
)
currents.load("ucos")
currents.load("vcos")
currents.set_interpolation_method("linear")
print(currents.at(40.0, -50.0, 1781524800))
Load from explicit filepaths
Use this when you already know the exact files you want.
from amphi_ncdata import NcData
waves = NcData.from_filepaths(
filepaths=[
"/tmp/20260614.nc",
"/tmp/20260615.nc",
],
start=1781391600,
end=1781478000,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
)
waves.load("swh")
waves.load("mwp")
waves.load("mwd")
waves.set_interpolation_method("linear")
print(waves.at(40.0, -50.0, 1781434800))
Sampling
currents.set_interpolation_method("linear")
value = currents.sample("ucos", 40.0, -50.0, 1781524800)
Batch sampling
For large point sets, batch APIs avoid Python per-sample overhead:
lats = [39.5, 40.0, 40.5]
lons = [-51.0, -50.0, -49.0]
timestamps = [1781521200, 1781524800, 1781528400]
u_values = currents.sample_batch("ucos", lats, lons, timestamps)
uv_values = currents.at_batch(lats, lons, timestamps)
Notes:
sample_batch()returns one NumPy arrayat_batch()returns one NumPy array shaped like(sample_count, variable_count)when multiple variables are loaded- use batch APIs when you already hold many independent points; for sequential, point-by-point access (e.g. routing),
at()/sample()are the right call
Supported interpolation values for set_interpolation_method():
"nearest""linear"
Lazy loading and cache
cache = {"size": 128 * 1024 * 1024, "nelems": 20000, "preemption": 0.8}
tidal = NcData.from_basepaths(
basepaths=["/data/data_api/FORECAST-TIDE/"],
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
max_forecast_days=7,
allow_last_available=True,
)
tidal.load("utotal", lazy=True, cache=cache)
tidal.load("vtotal", lazy=True, cache=cache)
Loaded variables keep their raw on-disk dtype in RAM (e.g. int16 for currents/tides/waves);
scale_factor, add_offset, and _FillValue are applied inside the Cython sampler at lookup
time. This halves resident memory for packed products versus decoding to float32 up front, with
no loss of precision. (The decode= argument to load() is retained for API compatibility but
is now a no-op — storage is always the raw dtype.)
Inspect loaded state
print(currents.resolved_files)
print(currents.loaded_variables)
print(currents.time_range)
print(currents.bounds)
print(currents.contains_timestamp(1781524800))
Context manager
from amphi_ncdata import NcData
with NcData.from_basepaths(
basepaths=["/data/data_api/FORECAST-CURRENT/"],
start=1781478000,
end=1781650800,
south=35.0,
west=-73.0,
north=43.0,
east=-30.0,
) as data:
data.load("ucos")
data.set_interpolation_method("linear")
print(data.sample("ucos", 40.0, -50.0, 1781524800))
Out-of-window behaviour
A query is "out of window" when its timestamp is outside the loaded time range,
or its lat/lon is outside the loaded bounding box. Choose what at() /
sample() (and the batch variants) return in that case:
wind.set_out_of_window_behaviour("nearest") # default: clamp to the nearest
# available timestep / edge cell
wind.set_out_of_window_behaviour("nan") # return NaN (handy to detect gaps)
wind.set_out_of_window_behaviour("value", 0.0) # return a fixed fill value
This is independent of NaN coming from the data itself (land / fill gaps), which is always returned as NaN regardless of the mode. The setting applies equally to out-of-range time and out-of-box space.
Timestamps And Coordinates
Accepted timestamp format:
- Unix timestamp integer in seconds
Sampling coordinate format:
latandlonare passed as separate numeric arguments
Errors
The package raises:
AmphiDiscoveryError: file discovery, open, or setup problemsAmphiVariableError: variable loading or sampling problemsAmphiNativeError: lower-level native errors
Example:
from amphi_ncdata import NcData, AmphiDiscoveryError
try:
NcData.from_basepaths(
["/tmp/does-not-exist"],
start=0,
end=1,
south=0,
west=0,
north=1,
east=1,
)
except AmphiDiscoveryError as exc:
print(exc)
Performance
at()andsample()are backed by a compiled sampler, so single-point lookups are fast. This is the intended path for routing algorithms (Dijkstra / A* / isochrone / DP) that sample one point at a time, where each step depends on the previous one.- Eager-loaded variables (the default) stay in memory, so sampling never touches disk. Use
lazy=Trueonly for variables too large to keep resident; lazy sampling reads from disk and is much slower per point. at_batch()/sample_batch()are fastest when you can gather many independent points and sample them in a single call.
Practical Notes
- A wheel is specific to an OS, Python version, and architecture.
- If you change Python versions, you may need a new wheel.
AmphiNcData(...)accepts missing path keys, but a dataset helper such asenv.wind()orenv.waves()still raises immediately if that dataset cannot be resolved.AmphiNcDatais the best starting point unless you specifically need file-level control.- This package focuses on point sampling, not bulk NumPy-style array workflows.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file amphi_ncdata-0.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.
File metadata
- Download URL: amphi_ncdata-0.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- Upload date:
- Size: 893.2 kB
- Tags: CPython 3.14, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7d714934feff2a96967f4e776f72770b0fd1020ecb3b2cb735e08c10bec88cd5
|
|
| MD5 |
0b0627d7a5f23b53e6fe0264f2a2d030
|
|
| BLAKE2b-256 |
101914456498ab79a383f57117ebaeb75f01583ddc81a383886e33a3b57d2a90
|