Metadata-Version: 2.4
Name: amphi-ncdata
Version: 0.1.0
Summary: Python client library for loading and sampling Amphitrite's NetCDF datasets.
Author: Amphitrite S.A.
License: Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: POSIX :: Linux
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: netCDF4>=1.6.5
Requires-Dist: numpy>=1.24
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: pytest>=8.0; extra == "dev"
Dynamic: requires-python

# 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:

```bash
python3 -m pip install /path/to/amphi_ncdata-0.1.0-<platform>.whl
```

Quick import check:

```bash
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:

```python
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 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

```python
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

```python
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

```python
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:

```python
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`:

```python
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:

```python
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:

```python
wind.set_out_of_window_behaviour("fallback", env.climWind(), dim="time")
```

So a long voyage just keeps sampling, with no manual gap handling:

```python
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()`

```python
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:

```python
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.

```python
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 `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.

```python
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.

```python
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

```python
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:

```python
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

Supported interpolation values for `set_interpolation_method()`:

- `"nearest"`
- `"linear"`

### Lazy loading and cache

```python
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

```python
print(currents.resolved_files)
print(currents.loaded_variables)
print(currents.time_range)
print(currents.bounds)
print(currents.contains_timestamp(1781524800))
```

### Context manager

```python
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:

```python
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:

```python
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()` 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 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.
