Skip to content

Validation & Coordinates

Coordinate-system hygiene for incoming datasets: validate longitude / latitude / time axes, decode CF time, rename coordinates and variables to or from CF standard names, and measure grid resolution.

CF standard names

RenameToCFStandardNames / RenameFromCFStandardNames use the curated Variable registry that now lives in xrproducts.types — the same registry that drives the colormap lookup in xrops.viz.

Coordinate validation

ValidateLongitude

Bases: Operator

Validate and CF-normalise the longitude coordinate.

Renames a recognised longitude alias to lon and checks the values lie in range, raising on malformed input.

Returns:

Type Description

The input dataset with a validated lon coordinate.

ValidateLatitude

Bases: Operator

Validate and CF-normalise the latitude coordinate.

Renames a recognised latitude alias to lat and checks the values lie in [-90, 90], raising on malformed input.

Returns:

Type Description

The input dataset with a validated lat coordinate.

ValidateCoords

Bases: Operator

Validate both longitude and latitude in one pass.

Applies :class:ValidateLongitude then :class:ValidateLatitude.

Returns:

Type Description

The input dataset with validated lon and lat coordinates.

ValidateTime

ValidateTime(*, time: str = 'time', unit: str | None = None, origin: str = 'unix')

Bases: Operator

Validate and standardise a time coordinate.

Parameters:

Name Type Description Default
time str

Name of the time coordinate.

'time'
unit str | None

Expected time unit; None infers it.

None
origin str

Reference epoch for numeric times (default "unix").

'unix'

Returns:

Type Description

The input dataset with a validated time coordinate.

Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(
    self,
    *,
    time: str = "time",
    unit: str | None = None,
    origin: str = "unix",
):
    self.time = time
    self.unit = unit
    self.origin = origin

DecodeCFTime

DecodeCFTime(*, time: str = 'time', units: str | None = None)

Bases: Operator

Decode a CF-encoded time coordinate to datetime64.

Parameters:

Name Type Description Default
time str

Name of the time coordinate to decode.

'time'
units str | None

CF time units (e.g. "days since 1990-01-01"); None reads them from the coordinate's units attribute.

None

Returns:

Type Description

The input dataset with time decoded to datetime64.

Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(self, *, time: str = "time", units: str | None = None):
    self.time = time
    self.units = units

Renaming & CF standard names

RenameCoords

RenameCoords(mapping: dict[str, str])

Bases: Operator

Rename coordinates by an explicit mapping.

Parameters:

Name Type Description Default
mapping dict[str, str]

{old_name: new_name} coordinate renames.

required

Returns:

Type Description

The input dataset with the coordinates renamed.

Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(self, mapping: dict[str, str]):
    self.mapping = dict(mapping)

RenameVariables

RenameVariables(mapping: dict[str, str])

Bases: Operator

Rename data variables by an explicit mapping.

Parameters:

Name Type Description Default
mapping dict[str, str]

{old_name: new_name} data-variable renames.

required

Returns:

Type Description

The input dataset with the data variables renamed.

Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(self, mapping: dict[str, str]):
    self.mapping = dict(mapping)

RenameToCFStandardNames

RenameToCFStandardNames(*, include_coords: bool = True)

Bases: Operator

Wrap :func:xrops.cf.rename_to_cf_standard_names.

Renames each variable / coord that carries a standard_name attr to that attr value. Raises ValueError on collision (two source vars mapping to the same standard_name).

Parameters:

Name Type Description Default
include_coords bool

If True (default), rename coords too.

True
Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(self, *, include_coords: bool = True) -> None:
    self.include_coords = include_coords

RenameFromCFStandardNames

RenameFromCFStandardNames(*, fallback: Literal['passthrough', 'raise'] = 'passthrough', include_coords: bool = True)

Bases: Operator

Wrap :func:xrops.cf.rename_from_cf_standard_names.

Renames CF standard_name-shaped variables to their xrops canonical names using the :mod:xrproducts.types.Variable registry.

Parameters:

Name Type Description Default
fallback Literal['passthrough', 'raise']

"passthrough" (default) leaves unrecognized names unchanged. "raise" raises KeyError on unknown names.

'passthrough'
include_coords bool

If True (default), rename coords too.

True
Source code in packages/xrtoolz-ops/src/xrops/cf/operators.py
def __init__(
    self,
    *,
    fallback: Literal["passthrough", "raise"] = "passthrough",
    include_coords: bool = True,
) -> None:
    self.fallback = fallback
    self.include_coords = include_coords

Functional primitives (Layer 0)

These pure functions back the operators above; each takes xr.DataArray/xr.Dataset and a dim: argument.

check_dataset_coords

check_dataset_coords(ds: Dataset, *, require: tuple[str, ...] = ('time', 'lat', 'lon'), validate: bool = True) -> None

Assert that required coordinates are present and (optionally) valid.

Parameters:

Name Type Description Default
ds Dataset

Dataset to check.

required
require tuple[str, ...]

Names that must appear in ds.coords. Data variables with these names do not satisfy the requirement — downstream coord-based ops like .sel need an actual coordinate.

('time', 'lat', 'lon')
validate bool

When True, run a round-trip through the appropriate validator for "lon", "lat", and "time" and assert the result is identical to the input. Pass False to skip the round-trip checks.

True

Raises:

Type Description
AssertionError

If any name in require is missing from ds.coords, or if a round-trip validator produces a different result (validate=True).

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def check_dataset_coords(
    ds: xr.Dataset,
    *,
    require: tuple[str, ...] = ("time", "lat", "lon"),
    validate: bool = True,
) -> None:
    """Assert that required coordinates are present and (optionally) valid.

    Args:
        ds: Dataset to check.
        require: Names that must appear in ``ds.coords``. Data
            variables with these names do not satisfy the requirement —
            downstream coord-based ops like ``.sel`` need an actual
            coordinate.
        validate: When ``True``, run a round-trip through the
            appropriate validator for ``"lon"``, ``"lat"``, and
            ``"time"`` and assert the result is identical to the
            input.  Pass ``False`` to skip the round-trip checks.

    Raises:
        AssertionError: If any name in ``require`` is missing from
            ``ds.coords``, or if a round-trip validator produces a
            different result (``validate=True``).
    """
    missing = set(require) - set(ds.coords)
    if missing:
        raise AssertionError(f"Dataset missing required coords: {sorted(missing)}")
    if validate:
        for name in require:
            fn = _COORD_VALIDATORS.get(name)
            if fn is not None:
                xr.testing.assert_identical(ds[[name]], fn(ds)[[name]])

validate_longitude

validate_longitude(ds: Dataset) -> Dataset

Normalize the longitude coordinate.

Renames longitude to lon if present, wraps values into the [-180, 180) range, and assigns CF units, standard_name, and long_name attributes (preserving any pre-existing attrs).

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required

Returns:

Type Description
Dataset

Dataset with a harmonized lon coordinate.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def validate_longitude(ds: xr.Dataset) -> xr.Dataset:
    """Normalize the longitude coordinate.

    Renames ``longitude`` to ``lon`` if present, wraps values into the
    ``[-180, 180)`` range, and assigns CF ``units``, ``standard_name``,
    and ``long_name`` attributes (preserving any pre-existing attrs).

    Args:
        ds: Input dataset.

    Returns:
        Dataset with a harmonized ``lon`` coordinate.
    """
    new_ds = _rename_first_match(ds, _LONGITUDE_ALIASES, "lon")
    if "lon" not in new_ds.coords and "lon" not in new_ds.variables:
        raise KeyError("No longitude coordinate found (expected 'lon' or 'longitude').")

    existing_attrs = dict(new_ds["lon"].attrs)
    new_ds["lon"] = lon_360_to_180(new_ds["lon"])
    new_ds["lon"] = new_ds["lon"].assign_attrs(**{**existing_attrs, **_LON_ATTRS})
    return new_ds

validate_latitude

validate_latitude(ds: Dataset) -> Dataset

Normalize the latitude coordinate.

Renames latitude to lat if present, wraps values into the [-90, 90) range, and assigns CF units, standard_name, and long_name attributes (preserving any pre-existing attrs).

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required

Returns:

Type Description
Dataset

Dataset with a harmonized lat coordinate.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def validate_latitude(ds: xr.Dataset) -> xr.Dataset:
    """Normalize the latitude coordinate.

    Renames ``latitude`` to ``lat`` if present, wraps values into the
    ``[-90, 90)`` range, and assigns CF ``units``, ``standard_name``,
    and ``long_name`` attributes (preserving any pre-existing attrs).

    Args:
        ds: Input dataset.

    Returns:
        Dataset with a harmonized ``lat`` coordinate.
    """
    new_ds = _rename_first_match(ds, _LATITUDE_ALIASES, "lat")
    if "lat" not in new_ds.coords and "lat" not in new_ds.variables:
        raise KeyError("No latitude coordinate found (expected 'lat' or 'latitude').")

    existing_attrs = dict(new_ds["lat"].attrs)
    new_ds["lat"] = lat_180_to_90(new_ds["lat"])
    new_ds["lat"] = new_ds["lat"].assign_attrs(**{**existing_attrs, **_LAT_ATTRS})
    return new_ds

validate_time

validate_time(ds: Dataset, *, time: str = 'time', unit: str | None = None, origin: str = 'unix') -> Dataset

Coerce the time coordinate to pandas datetime.

Strings and datetime64 arrays pass through unchanged. For numeric inputs, unit and origin must be supplied so the values can be interpreted unambiguously — otherwise :func:pandas.to_datetime defaults to nanoseconds since the Unix epoch, which silently mangles common "seconds since 1970-01-01" encodings.

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required
time str

Name of the time coordinate.

'time'
unit str | None

Forwarded to :func:pandas.to_datetime. For numeric coords, set this to e.g. "s" / "ms" / "D".

None
origin str

Forwarded to :func:pandas.to_datetime. Defaults to the Unix epoch.

'unix'

Returns:

Type Description
Dataset

Dataset with the time coordinate cast to datetime64[ns],

Dataset

preserving the original dims and attrs.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def validate_time(
    ds: xr.Dataset,
    *,
    time: str = "time",
    unit: str | None = None,
    origin: str = "unix",
) -> xr.Dataset:
    """Coerce the time coordinate to ``pandas`` datetime.

    Strings and ``datetime64`` arrays pass through unchanged. For
    numeric inputs, ``unit`` and ``origin`` must be supplied so the
    values can be interpreted unambiguously — otherwise
    :func:`pandas.to_datetime` defaults to nanoseconds since the Unix
    epoch, which silently mangles common "seconds since 1970-01-01"
    encodings.

    Args:
        ds: Input dataset.
        time: Name of the time coordinate.
        unit: Forwarded to :func:`pandas.to_datetime`. For numeric
            coords, set this to e.g. ``"s"`` / ``"ms"`` / ``"D"``.
        origin: Forwarded to :func:`pandas.to_datetime`. Defaults to
            the Unix epoch.

    Returns:
        Dataset with the time coordinate cast to ``datetime64[ns]``,
        preserving the original dims and attrs.
    """
    ds = ds.copy()
    coord = ds[time]
    converted = pd.to_datetime(coord.values, unit=unit, origin=origin)
    ds[time] = xr.DataArray(
        np.asarray(converted, dtype="datetime64[ns]"),
        dims=coord.dims,
        attrs=dict(coord.attrs),
    )
    return ds

decode_cf_time

decode_cf_time(ds: Dataset, *, time: str = 'time', units: str | None = None) -> Dataset

Assign a CF units attribute and decode the time coordinate.

Useful when raw integer / float time values and their epoch string arrive separately (e.g. CMEMS NetCDF or older OPeNDAP feeds).

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required
time str

Name of the time coordinate.

'time'
units str | None

CF units string (e.g. "days since 1950-01-01"). When None the attribute is left unchanged; if the coordinate is already datetime64 this is effectively a no-op.

None

Returns:

Type Description
Dataset

Dataset with the time coordinate decoded to datetime64.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def decode_cf_time(
    ds: xr.Dataset,
    *,
    time: str = "time",
    units: str | None = None,
) -> xr.Dataset:
    """Assign a CF ``units`` attribute and decode the time coordinate.

    Useful when raw integer / float time values and their epoch string
    arrive separately (e.g. CMEMS NetCDF or older OPeNDAP feeds).

    Args:
        ds: Input dataset.
        time: Name of the time coordinate.
        units: CF ``units`` string (e.g. ``"days since 1950-01-01"``).
            When ``None`` the attribute is left unchanged; if the
            coordinate is already ``datetime64`` this is effectively a
            no-op.

    Returns:
        Dataset with the time coordinate decoded to ``datetime64``.
    """
    ds = ds.copy()
    if units is not None:
        ds[time] = ds[time].assign_attrs(units=units)
    return xr.decode_cf(ds)

rename_coords

rename_coords(ds: Dataset, mapping: dict[str, str]) -> Dataset

Rename any coordinates or variables that match mapping.

Keys not present in ds are silently ignored, so this helper is safe to use as a first-pass harmonizer without pre-checking names.

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required
mapping dict[str, str]

{old_name: new_name}. Only names actually present are renamed.

required

Returns:

Type Description
Dataset

Dataset with matching names renamed.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def rename_coords(ds: xr.Dataset, mapping: dict[str, str]) -> xr.Dataset:
    """Rename any coordinates or variables that match ``mapping``.

    Keys not present in ``ds`` are silently ignored, so this helper is
    safe to use as a first-pass harmonizer without pre-checking names.

    Args:
        ds: Input dataset.
        mapping: ``{old_name: new_name}``. Only names actually present
            are renamed.

    Returns:
        Dataset with matching names renamed.
    """
    present = {old: new for old, new in mapping.items() if old in ds.variables}
    if not present:
        return ds
    return ds.rename(present)

rename_variables

rename_variables(ds: Dataset, mapping: dict[str, str]) -> Dataset

Rename data variables that match mapping.

Companion to :func:rename_coords: rename_variables only acts on entries in ds.data_vars so a typo in mapping doesn't silently rename a coordinate. Keys not present in ds.data_vars are ignored.

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def rename_variables(ds: xr.Dataset, mapping: dict[str, str]) -> xr.Dataset:
    """Rename data variables that match ``mapping``.

    Companion to :func:`rename_coords`: ``rename_variables`` only acts on
    entries in ``ds.data_vars`` so a typo in ``mapping`` doesn't silently
    rename a coordinate. Keys not present in ``ds.data_vars`` are
    ignored.
    """
    present = {old: new for old, new in mapping.items() if old in ds.data_vars}
    if not present:
        return ds
    return ds.rename(present)

rename_to_cf_standard_names

rename_to_cf_standard_names(ds: Dataset, *, include_coords: bool = True) -> Dataset

Rename variables to their declared CF standard_name attribute.

For every variable / coord with a non-empty standard_name attr, rename to that value. Variables without the attr are left unchanged.

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required
include_coords bool

If True (default), rename coords too. Set False to limit renaming to data variables only.

True

Returns:

Type Description
Dataset

Dataset with matching variables renamed to their CF

Dataset

standard_name.

Raises:

Type Description
ValueError

If two source variables resolve to the same CF standard_name (rename collision).

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def rename_to_cf_standard_names(
    ds: xr.Dataset,
    *,
    include_coords: bool = True,
) -> xr.Dataset:
    """Rename variables to their declared CF ``standard_name`` attribute.

    For every variable / coord with a non-empty ``standard_name`` attr,
    rename to that value. Variables without the attr are left unchanged.

    Args:
        ds: Input dataset.
        include_coords: If ``True`` (default), rename coords too. Set
            ``False`` to limit renaming to data variables only.

    Returns:
        Dataset with matching variables renamed to their CF
        ``standard_name``.

    Raises:
        ValueError: If two source variables resolve to the same CF
            ``standard_name`` (rename collision).
    """
    candidates: list[str] = (
        [str(k) for k in ds.variables]
        if include_coords
        else [str(k) for k in ds.data_vars]
    )
    mapping: dict[str, str] = {}
    for name in candidates:
        sn = ds[name].attrs.get("standard_name")
        if sn and sn != name:
            mapping[name] = str(sn)
    # Detect collisions: two source vars mapping to the same target name.
    inverse: dict[str, str] = {}
    for src, tgt in mapping.items():
        if tgt in inverse:
            raise ValueError(
                f"rename_to_cf_standard_names: two source variables "
                f"({inverse[tgt]!r} and {src!r}) both map to "
                f"standard_name={tgt!r}; cannot rename both."
            )
        inverse[tgt] = src
    # Detect collisions with names that already exist in the dataset
    # but aren't being renamed; xarray.rename would error on these,
    # but with a less helpful message.
    existing = set(ds.variables) - set(mapping)
    for src, tgt in mapping.items():
        if tgt in existing:
            raise ValueError(
                f"rename_to_cf_standard_names: source {src!r} maps to "
                f"standard_name={tgt!r}, which already exists in the "
                "dataset; rename or drop the existing variable first."
            )
    return ds.rename(mapping) if mapping else ds

rename_from_cf_standard_names

rename_from_cf_standard_names(ds: Dataset, *, fallback: Literal['passthrough', 'raise'] = 'passthrough', include_coords: bool = True) -> Dataset

Rename CF standard_name-shaped variables to xrops canonical names.

Uses the :mod:xrproducts.types.Variable registry as the authoritative standard_name → canonical_name mapping. Variables / coords whose name is not a registered CF standard_name pass through unchanged (fallback="passthrough", default) or raise KeyError (fallback="raise").

Single-word names (e.g. "ssh") are never flagged as unknown — they are already canonical and pass through silently regardless of fallback.

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required
fallback Literal['passthrough', 'raise']

"passthrough" (default) leaves unrecognized CF-shaped names unchanged. "raise" raises KeyError listing all unrecognized names.

'passthrough'
include_coords bool

If True (default), rename coords too.

True

Returns:

Type Description
Dataset

Dataset with CF-named variables renamed to their canonical

Dataset

xrops names.

Raises:

Type Description
ValueError

If fallback is not "passthrough" or "raise".

KeyError

If fallback="raise" and any variable name looks like a CF standard_name (contains "_") but is not in the registry — and is also not itself a registered canonical name (e.g. "sst_obs", "analysed_sst").

Source code in packages/xrtoolz-ops/src/xrops/cf/_src/validation.py
def rename_from_cf_standard_names(
    ds: xr.Dataset,
    *,
    fallback: Literal["passthrough", "raise"] = "passthrough",
    include_coords: bool = True,
) -> xr.Dataset:
    """Rename CF ``standard_name``-shaped variables to xrops canonical names.

    Uses the :mod:`xrproducts.types.Variable` registry as the authoritative
    ``standard_name → canonical_name`` mapping. Variables / coords whose
    name is not a registered CF ``standard_name`` pass through unchanged
    (``fallback="passthrough"``, default) or raise ``KeyError``
    (``fallback="raise"``).

    Single-word names (e.g. ``"ssh"``) are never flagged as unknown — they
    are already canonical and pass through silently regardless of
    ``fallback``.

    Args:
        ds: Input dataset.
        fallback: ``"passthrough"`` (default) leaves unrecognized
            CF-shaped names unchanged. ``"raise"`` raises ``KeyError``
            listing all unrecognized names.
        include_coords: If ``True`` (default), rename coords too.

    Returns:
        Dataset with CF-named variables renamed to their canonical
        xrops names.

    Raises:
        ValueError: If ``fallback`` is not ``"passthrough"`` or
            ``"raise"``.
        KeyError: If ``fallback="raise"`` and any variable name looks
            like a CF ``standard_name`` (contains ``"_"``) but is not
            in the registry — and is also not itself a registered
            canonical name (e.g. ``"sst_obs"``, ``"analysed_sst"``).
    """
    if fallback not in ("passthrough", "raise"):
        raise ValueError(
            f"fallback must be 'passthrough' or 'raise'; got {fallback!r}."
        )
    cf_to_canonical = _build_cf_index()
    canonical_names = _canonical_name_set()
    candidates: list[str] = (
        [str(k) for k in ds.variables]
        if include_coords
        else [str(k) for k in ds.data_vars]
    )
    mapping: dict[str, str] = {}
    unknown: list[str] = []
    for name in candidates:
        if name in cf_to_canonical:
            canon = cf_to_canonical[name]
            if canon != name:
                mapping[name] = canon
        elif "_" in name and name not in canonical_names:
            # Only flag as unknown if the name looks like a CF standard_name
            # (snake_case multi-word) AND isn't a registered canonical name.
            # Single-word names ("ssh") and underscored canonicals
            # ("sst_obs", "analysed_sst") pass through silently.
            unknown.append(name)
    if unknown and fallback == "raise":
        raise KeyError(
            f"rename_from_cf_standard_names: unknown CF standard_name(s) "
            f"{unknown!r}. Pass fallback='passthrough' to ignore, or "
            "extend the Variable registry."
        )
    return ds.rename(mapping) if mapping else ds

calc_latlon

calc_latlon(ds: Dataset) -> Dataset

Add 2-D latitude / longitude coords computed from x/y.

Assumes ds has 1-D x and y coordinates and a CRS attached (ds.rio.crs). Inf values from the transform are replaced with NaN so that downstream masking handles them cleanly. This is the inverse direction of :func:assign_local_xy for gridded rasters: projected x/y axes in, geographic coordinates out.

Parameters:

Name Type Description Default
ds Dataset

Input dataset.

required

Returns:

Type Description
Dataset

Dataset with additional 2-D latitude / longitude

Dataset

coordinates along (y, x).

Source code in packages/xrtoolz-ops/src/xrops/geom/_src/crs.py
def calc_latlon(ds: xr.Dataset) -> xr.Dataset:
    """Add 2-D ``latitude`` / ``longitude`` coords computed from x/y.

    Assumes ``ds`` has 1-D ``x`` and ``y`` coordinates and a CRS attached
    (``ds.rio.crs``). Inf values from the transform are replaced with
    NaN so that downstream masking handles them cleanly. This is the
    inverse direction of :func:`assign_local_xy` for gridded rasters:
    projected ``x``/``y`` axes in, geographic coordinates out.

    Args:
        ds: Input dataset.

    Returns:
        Dataset with additional 2-D ``latitude`` / ``longitude``
        coordinates along ``(y, x)``.
    """
    if ds.rio.crs is None:
        raise ValueError(
            "assign a CRS with assign_crs(ds, ...) before calling calc_latlon."
        )

    xx, yy = np.meshgrid(ds.x.values, ds.y.values)
    lons, lats = xy_to_lonlat(str(ds.rio.crs), xx, yy)
    lons = np.where(np.isfinite(lons), lons, np.nan)
    lats = np.where(np.isfinite(lats), lats, np.nan)

    ds = ds.assign_coords(
        longitude=(("y", "x"), lons),
        latitude=(("y", "x"), lats),
    )
    ds["longitude"].attrs["units"] = "degrees_east"
    ds["latitude"].attrs["units"] = "degrees_north"
    return ds

get_dataset_resolution

get_dataset_resolution(ds: Dataset, *, lon: str = 'lon', lat: str = 'lat', rtol: float = 0.05) -> GridResolution

Classify a regular lat/lon grid against canonical OceanBench resolutions.

Returns one of "one_degree", "quarter_degree", "twelfth_degree", or "other" (no canonical resolution matches within rtol of both axes). Useful for dispatch — e.g. picking the right MDT URL or the right reference dataset for a given grid.

The classifier checks the median absolute spacing of the lon and lat coordinates independently — an anisotropic grid (e.g. dlon=1.1 but dlat=0.9) is rejected even though its averaged spacing would round to 1°. Curvilinear (2-D) lon/lat coordinates are rejected with a ValueError: np.diff on a multi-dim array would silently mix endpoints across rows and misclassify the grid.

Parameters:

Name Type Description Default
ds Dataset

Dataset carrying 1-D lon / lat coordinates.

required
lon str

Longitude coordinate name.

'lon'
lat str

Latitude coordinate name.

'lat'
rtol float

Relative tolerance for the canonical-resolution match, applied per-axis.

0.05

Returns:

Type Description
GridResolution

Resolution label as a Literal[...] string.

Raises:

Type Description
ValueError

If ds[lon] or ds[lat] is not 1-D — the classifier only operates on rectilinear grids; curvilinear datasets should be regridded first.

Source code in packages/xrtoolz-ops/src/xrops/geom/_src/crs.py
def get_dataset_resolution(
    ds: xr.Dataset,
    *,
    lon: str = "lon",
    lat: str = "lat",
    rtol: float = 0.05,
) -> GridResolution:
    """Classify a regular lat/lon grid against canonical OceanBench resolutions.

    Returns one of ``"one_degree"``, ``"quarter_degree"``,
    ``"twelfth_degree"``, or ``"other"`` (no canonical resolution
    matches within ``rtol`` of *both* axes). Useful for dispatch — e.g.
    picking the right MDT URL or the right reference dataset for a
    given grid.

    The classifier checks the median absolute spacing of the ``lon``
    and ``lat`` coordinates **independently** — an anisotropic grid
    (e.g. ``dlon=1.1`` but ``dlat=0.9``) is rejected even though its
    averaged spacing would round to 1°. Curvilinear (2-D) lon/lat
    coordinates are rejected with a ``ValueError``: ``np.diff`` on a
    multi-dim array would silently mix endpoints across rows and
    misclassify the grid.

    Args:
        ds: Dataset carrying 1-D ``lon`` / ``lat`` coordinates.
        lon: Longitude coordinate name.
        lat: Latitude coordinate name.
        rtol: Relative tolerance for the canonical-resolution match,
            applied per-axis.

    Returns:
        Resolution label as a ``Literal[...]`` string.

    Raises:
        ValueError: If ``ds[lon]`` or ``ds[lat]`` is not 1-D — the
            classifier only operates on rectilinear grids; curvilinear
            datasets should be regridded first.
    """
    lon_da = ds[lon]
    lat_da = ds[lat]
    if lon_da.ndim != 1:
        raise ValueError(
            f"get_dataset_resolution expects a 1-D {lon!r} coord, got "
            f"shape {tuple(lon_da.shape)}. Curvilinear / 2-D grids are not "
            "supported — regrid onto a rectilinear axis first."
        )
    if lat_da.ndim != 1:
        raise ValueError(
            f"get_dataset_resolution expects a 1-D {lat!r} coord, got "
            f"shape {tuple(lat_da.shape)}. Curvilinear / 2-D grids are not "
            "supported — regrid onto a rectilinear axis first."
        )
    dlon = float(np.median(np.abs(np.diff(np.asarray(lon_da.values, dtype=float)))))
    dlat = float(np.median(np.abs(np.diff(np.asarray(lat_da.values, dtype=float)))))
    candidates: list[tuple[GridResolution, float]] = [
        ("one_degree", 1.0),
        ("quarter_degree", 0.25),
        ("twelfth_degree", 1.0 / 12.0),
    ]
    for label, target in candidates:
        # Require BOTH axes to match the canonical target — averaging
        # would let an anisotropic grid (dlon=1.1, dlat=0.9 → mean=1.0)
        # masquerade as canonical even though neither axis is.
        if abs(dlon - target) < rtol * target and abs(dlat - target) < rtol * target:
            return label
    return "other"

median_dx_km

median_dx_km(lon: ArrayLike, lat: ArrayLike) -> float

Median geodesic spacing between consecutive points in kilometres.

The inputs must be 1-D — a flattened multi-track array would mix endpoints from unrelated tracks into the spacing estimate, biasing the inferred FIR cutoff. For multi-track datasets, derive spacing per track or pass spacing_km to :func:bandpass_wavelength explicitly.

Parameters:

Name Type Description Default
lon ArrayLike

1-D longitude samples in degrees.

required
lat ArrayLike

1-D latitude samples in degrees, same shape as lon.

required

Returns:

Type Description
float

Median WGS-84 geodesic segment length in kilometres.

Source code in packages/xrtoolz-ops/src/xrops/wavelets/_src/along_track.py
def median_dx_km(lon: ArrayLike, lat: ArrayLike) -> float:
    """Median geodesic spacing between consecutive points in kilometres.

    The inputs must be 1-D — a flattened multi-track array would mix
    endpoints from unrelated tracks into the spacing estimate, biasing
    the inferred FIR cutoff. For multi-track datasets, derive spacing
    per track or pass ``spacing_km`` to :func:`bandpass_wavelength`
    explicitly.

    Args:
        lon: 1-D longitude samples in degrees.
        lat: 1-D latitude samples in degrees, same shape as ``lon``.

    Returns:
        Median WGS-84 geodesic segment length in kilometres.
    """
    lon_arr = np.asarray(lon, dtype=float)
    lat_arr = np.asarray(lat, dtype=float)
    if lon_arr.shape != lat_arr.shape:
        raise ValueError(
            f"lon and lat must have the same shape; got {lon_arr.shape} and "
            f"{lat_arr.shape}"
        )
    if lon_arr.ndim != 1:
        raise ValueError(
            "median_dx_km expects 1-D inputs along a single track; got shape "
            f"{lon_arr.shape}. Pass spacing_km explicitly for multi-track data."
        )
    if lon_arr.size < 2:
        raise ValueError("at least two lon/lat points are required")

    distances_m = np.asarray(_GEOD.line_lengths(lon_arr, lat_arr), dtype=float)
    finite = distances_m[np.isfinite(distances_m)]
    if finite.size == 0:
        raise ValueError("no finite geodesic segment lengths found")
    return float(np.median(finite) / 1000.0)

geometric_scales

geometric_scales(s0: float, *, octaves: float, voices_per_octave: int = 8, name: str = 'scale') -> DataArray

Return a geometric wavelet scale grid.

Parameters:

Name Type Description Default
s0 float

Smallest positive scale.

required
octaves float

Number of octaves to span from s0.

required
voices_per_octave int

Number of samples per octave.

8
name str

Scale dimension and DataArray name.

'scale'

Returns:

Type Description
DataArray

DataArray with one dimension named name.

Source code in packages/xrtoolz-ops/src/xrops/wavelets/_src/wavelet_utils.py
def geometric_scales(
    s0: float,
    *,
    octaves: float,
    voices_per_octave: int = 8,
    name: str = "scale",
) -> xr.DataArray:
    """Return a geometric wavelet scale grid.

    Args:
        s0: Smallest positive scale.
        octaves: Number of octaves to span from ``s0``.
        voices_per_octave: Number of samples per octave.
        name: Scale dimension and DataArray name.

    Returns:
        DataArray with one dimension named ``name``.
    """
    if s0 <= 0:
        raise ValueError("s0 must be strictly positive")
    if octaves < 0:
        raise ValueError("octaves must be non-negative")
    if voices_per_octave <= 0:
        raise ValueError("voices_per_octave must be positive")
    n = int(np.floor(octaves * voices_per_octave)) + 1
    values = s0 * 2.0 ** (np.arange(n, dtype=float) / voices_per_octave)
    return xr.DataArray(values, dims=(name,), coords={name: values}, name=name)

scale_to_wavenumber

scale_to_wavenumber(scales: DataArray | ndarray, *, x0: float, k0: float = 1.0) -> DataArray | ndarray

Convert Morlet scales to central wavenumber k = k0 / (s x0).

Source code in packages/xrtoolz-ops/src/xrops/wavelets/_src/wavelet_utils.py
def scale_to_wavenumber(
    scales: xr.DataArray | np.ndarray,
    *,
    x0: float,
    k0: float = 1.0,
) -> xr.DataArray | np.ndarray:
    """Convert Morlet scales to central wavenumber ``k = k0 / (s x0)``."""
    if x0 <= 0:
        raise ValueError("x0 must be strictly positive")
    if k0 <= 0:
        raise ValueError("k0 must be strictly positive")
    return k0 / (scales * x0)

wavenumber_to_scale

wavenumber_to_scale(wavenumber: DataArray | ndarray, *, x0: float, k0: float = 1.0) -> DataArray | ndarray

Convert central wavenumber to Morlet scale.

Source code in packages/xrtoolz-ops/src/xrops/wavelets/_src/wavelet_utils.py
def wavenumber_to_scale(
    wavenumber: xr.DataArray | np.ndarray,
    *,
    x0: float,
    k0: float = 1.0,
) -> xr.DataArray | np.ndarray:
    """Convert central wavenumber to Morlet scale."""
    if x0 <= 0:
        raise ValueError("x0 must be strictly positive")
    if k0 <= 0:
        raise ValueError("k0 must be strictly positive")
    return k0 / (wavenumber * x0)