Skip to main content

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: an AmphiNcData-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

  • AmphiNcData is the convenience layer for Amphi folder layouts, masks, and tides.
  • NcData is 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" (another NcData). 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 (roughly a few dozen or more) to sample at once. Pass out= (a buffer from empty_batch()) to sample in place with no per-call allocation.
  • 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(...) -> bathymetry
  • env.nogo().at(...) -> mask
  • env.coastal_zone().at(...) -> mask
  • env.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 constant value (a float).
    • "fallback" (alias "data") — sample value instead, which must be another NcData that 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 in C, O(1)
uv   = currents.at_batch(lats, lons, timestamps)    # routed + sampled in C, one call

buf = currents.empty_batch(len(lats))               # reusable (n, 2) float32 buffer
currents.at_batch(lats, lons, timestamps, out=buf)  # fill in place, returns buf

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.
  • Both .at() and .at_batch() run compiled, matching a plain dataset: at/sample route each point in C, and at_batch/sample_batch route and sample every point in C (no Python bucketing). They accept the same out= buffer and empty_batch() helper (see Reusing an output buffer with out= under Using NcData below), so batching is fast from small sizes up.
  • Overlap → first listed box wins. When boxes overlap in both space and time, the earliest box in boxes that 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 (default NaN).
  • 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 array
  • at_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

Reusing an output buffer with out=

at_batch() and sample_batch() accept an optional out= buffer: results are written into it in place and the same array is returned — no per-call allocation. Build the correctly-shaped, correctly-typed buffer once with empty_batch() and reuse it across calls (e.g. re-sampling the same points on every step of a loop):

import numpy as np

lats = [39.5, 40.0, 40.5]
lons = [-51.0, -50.0, -49.0]
timestamps = [1781521200, 1781524800, 1781528400]

buf = currents.empty_batch(len(lats))                 # (n, 2) float32 for a 2-variable dataset
currents.at_batch(lats, lons, timestamps, out=buf)    # returns buf, filled in place

sbuf = np.empty(len(lats), dtype=np.float32)          # (n,) float32 for a single variable
currents.sample_batch("ucos", lats, lons, timestamps, out=sbuf)

out must be a C-contiguous float32 array of shape (n, variable_count) for at_batch() — or (n,) when the dataset has a single loaded variable, and always (n,) for sample_batch(). empty_batch(n) returns exactly the shape at_batch() needs. Passing out= implies a NumPy-array return (the as_numpy flag is ignored).

When to batch: the batch APIs run the same compiled sampler as at() / sample(), but in one call. They carry a small fixed per-call cost, so for just a few points a plain at() loop is still faster; batching pays off from roughly a few dozen points upward and scales to millions.

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:

  • lat and lon are passed as separate numeric arguments

Errors

The package raises:

  • AmphiDiscoveryError: file discovery, open, or setup problems
  • AmphiVariableError: variable loading or sampling problems
  • AmphiNativeError: 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() and sample() 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=True only for variables too large to keep resident; lazy sampling reads from disk and is much slower per point.
  • at_batch() / sample_batch() run the same compiled sampler over many points in one call. They carry a small fixed per-call cost, so use them once you have roughly a few dozen points or more (below that, an at() loop wins). Pass out= (from empty_batch()) to avoid per-call allocation when sampling repeatedly.

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 as env.wind() or env.waves() still raises immediately if that dataset cannot be resolved.
  • AmphiNcData is 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

amphi_ncdata-0.2.0-cp314-cp314-win_amd64.whl (384.3 kB view details)

Uploaded CPython 3.14Windows x86-64

amphi_ncdata-0.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (996.2 kB view details)

Uploaded CPython 3.14manylinux: glibc 2.17+ x86-64

File details

Details for the file amphi_ncdata-0.2.0-cp314-cp314-win_amd64.whl.

File metadata

File hashes

Hashes for amphi_ncdata-0.2.0-cp314-cp314-win_amd64.whl
Algorithm Hash digest
SHA256 a14ae549a0882142291bcebdeeafb3e2ad57163084d151a887d20204e129c299
MD5 4b414ba09e05f6264cc0c2a7803a7fa9
BLAKE2b-256 395887fa945980766f117e5ed3e76fa7e3256143528bc0fad1da4b9c2fe731fc

See more details on using hashes here.

File details

Details for the file amphi_ncdata-0.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for amphi_ncdata-0.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 e3d21f91997278c7571310b30d6d2126dd1fb749f34130559f6ff931e83bc809
MD5 f291d034b28bf11e8cff0e995c3cbd21
BLAKE2b-256 419cede730c94d8629d32128e8921970134b1686125e3bab98ffb318943b1b3e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page