Source code for xdas.coordinates.core

"""
Core coordinate infrastructure.

Includes the :class:`Coordinates` container, :class:`Coordinate` factory/base
class, and shared helpers used by all concrete coordinate types (parsing,
interpolation, tolerance handling).
"""

import weakref
from abc import ABC, abstractmethod
from copy import copy, deepcopy
from functools import wraps
from itertools import pairwise

import numpy as np
import pandas as pd


def wraps_first_last(func):
    """Resolve ``"first"`` and ``"last"`` dim aliases before calling *func*."""

    @wraps(func)
    def wrapper(self, dim, *args, **kwargs):
        """Resolve ``"first"``/``"last"`` aliases then delegate to *func*."""
        if dim == "first":
            dim = self._dims[0]
        if dim == "last":
            dim = self._dims[-1]
        return func(self, dim, *args, **kwargs)

    return wrapper


def wraps_first_last_all(func):
    """Resolve ``"first"`` and ``"last"`` aliases in every positional argument."""

    @wraps(func)
    def wrapper(self, *args, **kwargs):
        resolved = tuple(
            self._dims[0] if a == "first" else (self._dims[-1] if a == "last" else a)
            for a in args
        )
        return func(self, *resolved, **kwargs)

    return wrapper


[docs] class Coordinates(dict): """ Dictionary like container for coordinates. Parameters ---------- coords: dict-like, optional Mapping from coordinate names to any of the followings: - Coordinate objects - tuples (dim, coordinate-like) which can be either dimensional (`dim == name`) or non-dimensional (`dim != name` or `dim == None`). - coordinate-like objects (that are passed to the Coordinate constructor) which are assumed to be a dimensional coordinate with `dim` set to the related name. dims: sequence of str, optional An ordered sequence of dimensions. It is meant to match the dimensionality of its associated data. If provided, it must at least include all dimensions found in `coords` (extras dimensions will be considered as empty coordinates). Otherwise, dimensions will be guessed from `coords`. Examples -------- >>> import xdas as xd >>> coords = { ... "time": {"tie_indices": [0, 999], "tie_values": [0.0, 10.0]}, ... "distance": [0, 1, 2], ... "channel": ("distance", ["DAS01", "DAS02", "DAS03"]), ... "interrogator": (None, "SRN"), ... } >>> xd.Coordinates(coords) Coordinates: * time (time): 0.000 to 10.000 * distance (distance): [0 ... 2] channel (distance): ['DAS01' ... 'DAS03'] interrogator: 'SRN' """
[docs] def __init__(self, coords=None, dims=None): super().__init__() if isinstance(coords, Coordinates): if dims is None: dims = coords.dims coords = dict(coords) self._dims = () if dims is None else tuple(dims) if coords is not None: for name in coords: self[name] = coords[name]
@wraps_first_last def __getitem__(self, key): if key in self.dims and key not in self: raise KeyError(f"dimension {key} has no coordinate") return super().__getitem__(key) @wraps_first_last def __setitem__(self, key, value): if not isinstance(key, str): raise TypeError("dimension names must be of type str") coord = Coordinate(value) if coord.dim is None and not coord.isscalar(): coord.dim = key if self.parent is None: if coord.dim is not None and coord.dim not in self.dims: self._dims = self.dims + (coord.dim,) else: if coord.dim is not None: if coord.dim not in self.dims: raise KeyError( f"cannot add new dimension {coord.dim} to an existing DataArray" ) size = self.parent.sizes[coord.dim] if not len(coord) == size: raise ValueError( f"conflicting sizes for dimension {coord.dim}: size {len(coord)} " f"in `coords` and size {size} in `data`" ) coord._assign_parent(self) return super().__setitem__(key, coord) def __repr__(self): lines = ["Coordinates:"] for name, coord in self.items(): if self.isdim(name): lines.append(f" * {name} ({coord.dim}): {coord}") else: if coord.dim is None: lines.append(f" {name}: {coord}") else: lines.append(f" {name} ({coord.dim}): {coord}") return "\n".join(lines) def __reduce__(self): return self.__class__, (dict(self), self.dims) @property def dims(self): """Ordered tuple of dimension names for this coordinates container.""" return self._dims @property def parent(self): """The parent object (usually a :class:`DataArray`) this container is attached to.""" if hasattr(self, "_parent"): return self._parent() else: return None
[docs] def isdim(self, name): """Return ``True`` if *name* is a dimensional coordinate (i.e. its dim equals its name).""" return self[name].dim == name
def _get_query(self, item): """ Format a query from one or multiple indexer. Parameters ---------- item: indexer-like or sequence or mapping Object to be parsed as a query. If item is indexer-like object, it is applied on the first dimension. If item is a sequence, positional indexing is performed. If item is a mapping, labeled indexing is performed. Returns ------- dict: A mapping between each dim and a given indexer. If No indexer was found for a given dim, slice(None) will be used. """ query = {dim: slice(None) for dim in self.dims} if isinstance(item, dict): if "first" in item: item[self.dims[0]] = item.pop("first") if "last" in item: item[self.dims[-1]] = item.pop("last") query.update(item) elif isinstance(item, tuple): for k in range(len(item)): query[self.dims[k]] = item[k] else: query[self.dims[0]] = item for dim, item in query.items(): if isinstance(item, tuple): msg = f"cannot use tuple {item} to index dim '{dim}'" if len(item) == 2: msg += f". Did you mean: {dim}=slice({item[0]}, {item[1]})?" raise TypeError(msg) return query
[docs] def to_index(self, item, method=None, endpoint=True): """ Convert an item selector to a dict of per-dimension integer indices. Parameters ---------- item : indexer-like, sequence, or mapping Passed to :meth:`get_query` to resolve dimension-by-dimension indexers. method : str, optional Interpolation method forwarded to each coordinate's :meth:`~Coordinate.to_index`. endpoint : bool, optional Whether to include the stop endpoint of slice selectors. Default ``True``. Returns ------- dict Mapping from dimension name to integer index or slice. """ query = self._get_query(item) return {dim: self[dim].to_index(query[dim], method, endpoint) for dim in query}
[docs] def equals(self, other): """Return ``True`` if *other* is a :class:`Coordinates` with identical coordinate values.""" if not isinstance(other, Coordinates): return False if set(self) != set(other): return False for name in self: if not self[name].equals(other[name]): return False return True
@classmethod def _from_dataset(cls, dataset, name): """Build a :class:`Coordinates` by delegating to each registered coordinate subclass.""" return cls(Coordinate._from_dataset(dataset, name))
[docs] def copy(self, deep=True): """Return a copy of this :class:`Coordinates` container. Parameters ---------- deep : bool, optional If ``True`` (default) perform a deep copy of every coordinate. """ return self.__class__( {key: value.copy(deep) for key, value in self.items()}, self.dims )
[docs] @wraps_first_last_all def drop_dims(self, *dims): """Return a new :class:`Coordinates` with *dims* and their associated coordinates removed.""" coords = {key: value for key, value in self.items() if value.dim not in dims} dims = tuple(value for value in self.dims if value not in dims) return self.__class__(coords, dims)
[docs] @wraps_first_last_all def drop_coords(self, *names): """Return a new :class:`Coordinates` with the named coordinates removed.""" coords = {key: value for key, value in self.items() if key not in names} return self.__class__(coords, self.dims)
def _assign_parent(self, parent): """Attach this container to its parent, validating dimension counts and sizes.""" if not len(self.dims) == parent.ndim: raise ValueError( f"inferred number of dimensions {len(self.dims)} from `coords` does " f"not match `data` dimensionality of {parent.ndim}" ) for dim, size in zip(self.dims, parent.shape): if (dim in self) and (not len(self[dim]) == size): raise ValueError( f"conflicting sizes for dimension {dim}: size {len(self[dim])} " f"in `coords` and size {size} in `data`" ) self._parent = weakref.ref(parent)
[docs] class Coordinate(ABC): """ Base class and factory for all coordinate types. A coordinate maps the integer positions of one array axis to physical values (e.g. timestamps, distances). It supports two complementary directions of lookup: - **Index-based selection** — ``coord[i]`` or ``coord[start:stop]``: given integer position(s), return the corresponding physical value(s) as a new coordinate. - **Label-based selection** — ``coord.to_index(v)``: given a physical value (or slice of values), return the integer index (or slice) at that label. An optional *method* argument controls nearest/forward/ backward matching for values that fall between samples. The returned index can then be passed to ``coord[idx]`` to retrieve the coordinate subset, and is also used internally to index into the parent data array. **Factory behaviour** — calling ``Coordinate(data)`` directly acts as a factory: it inspects *data* and returns an instance of the most suitable registered subclass (:class:`SampledCoordinate`, :class:`InterpCoordinate`, :class:`DenseCoordinate`, or :class:`ScalarCoordinate`). **Subclassing** — register a new subclass by passing ``ctype=`` in the class definition:: class MyCoord(Coordinate, ctype="mycoord"): ... Parameters ---------- data : array-like or mapping The coordinate data. Interpretation is subclass-specific. dim : str, optional Name of the dimension this coordinate is associated with. dtype : dtype-like, optional Desired dtype for the underlying data array. """ # --- class machinery --- _registry = {} def __init_subclass__(cls, *, ctype=None, **kwargs): super().__init_subclass__(**kwargs) if ctype is not None: Coordinate._registry[ctype] = cls def __class_getitem__(cls, item): return cls._registry[item] def __new__(cls, data=None, dim=None, dtype=None): """Instantiate the appropriate Coordinate subclass based on *data*.""" # class factory if instantiating Coordinate directly if cls is Coordinate: if data is None: raise TypeError("cannot infer coordinate type if no `data` is provided") data, dim = parse(data, dim) for subcls in Coordinate._registry.values(): if subcls._isvalid(data): cls = subcls break else: raise TypeError("could not parse `data`") # normal allocation return super().__new__(cls) # --- abstract contract ---
[docs] @abstractmethod def __init__(self, data=None, dim=None, dtype=None): """Initialise the coordinate from subclass-specific *data*."""
@classmethod @abstractmethod def from_block(cls, start, size, step, dim=None, dtype=None): """ Construct a coordinate from a start value, element count, and step size. Parameters ---------- start : scalar Value of the first element. size : int Number of elements. step : scalar Spacing between consecutive elements. dim : str, optional Dimension name. dtype : dtype-like, optional Desired dtype for the coordinate values. Returns ------- Coordinate A new coordinate instance of this subclass. """ @abstractmethod def __len__(self): """Return the number of elements along this coordinate's axis.""" @property @abstractmethod def dtype(self): """NumPy dtype of the underlying coordinate values.""" @staticmethod @abstractmethod def _isvalid(data): """Return ``True`` if *data* is a valid input for this coordinate subclass.""" @abstractmethod def _is_monotonic_increasing(self): """Return ``True`` if all consecutive differences in this coordinate are positive.""" @abstractmethod def _get_value(self, index): """ Return the coordinate value(s) at integer *index*. Parameters ---------- index : int or numpy.ndarray of int Non-negative integer index or array of indices. Returns ------- scalar or numpy.ndarray Coordinate value(s) at the requested position(s). """ @abstractmethod def _get_indexer(self, value, method=None): """ Return the integer index for label *value* using the segment structure. Parameters ---------- value : scalar, str (ISO datetime), or array-like Label(s) to locate. method : {None, "nearest", "ffill", "bfill"}, optional How to handle values that fall in gaps or between samples. ``None`` (default) requires an exact match and raises ``KeyError`` if the value is not present. ``"nearest"`` returns the index of the closest label. ``"ffill"`` (forward-fill) returns the last index whose label is less than or equal to *value*. ``"bfill"`` (backward-fill) returns the first index whose label is greater than or equal to *value*. Returns ------- int or numpy.ndarray Raises ------ KeyError If *value* falls in an overlap region or is not found (exact mode). """ @abstractmethod def _slice(self, slc): """ Return a new coordinate covering the integer slice *slc*. Parameters ---------- slc : slice Integer slice (already normalised by the caller). Returns ------- Coordinate A new coordinate of the same subclass. """ @abstractmethod def _concat(self, other): """ Return a new coordinate formed by appending *other* after this one. Parameters ---------- other : Coordinate Must be the same subclass and have the same ``dim`` and ``dtype``. Returns ------- Coordinate Concatenated coordinate of the same subclass. s """ @abstractmethod def _to_dataset(self, dataset, attrs): """ Serialise this coordinate into an xarray *dataset*, updating *attrs* in place. Parameters ---------- dataset : xarray.Dataset Target dataset to write coordinate data into. attrs : dict Global attribute mapping to update (e.g. ``coordinate_interpolation``). Returns ------- dataset : xarray.Dataset attrs : dict """ @classmethod @abstractmethod def _collect_from_dataset(cls, dataset, name): """ Extract coordinates of this subclass's type from *dataset* variable *name*. Parameters ---------- dataset : xarray.Dataset Source dataset. name : str Name of the variable whose coordinates should be extracted. Returns ------- dict Mapping from coordinate name to coordinate-like data, ready to be passed to :class:`Coordinate`. """ # -- properties --- #: Name of the dimension this coordinate is associated with, or ``None``. dim = None @property def ndim(self): """Number of dimensions (always 1 for dimensional coordinates).""" return 1 @property def shape(self): """Shape tuple ``(len(self),)``.""" return (len(self),) @property def size(self): """Number of elements along this coordinate's axis.""" return len(self) @property def empty(self): """``True`` if the coordinate has zero length.""" return len(self) == 0 @property def indices(self): """Integer array ``[0, 1, ..., len(self) - 1]``.""" return np.arange(len(self)) @property def values(self): """Materialised numpy array of coordinate values.""" return self.__array__(copy=False) @property def start(self): """Value at index 0 (first element).""" return self._get_value(0) @property def end(self): """Value at the last element.""" return self._get_value(len(self) - 1) @property def parent(self): """The parent :class:`Coordinates` container, or ``None`` if unattached.""" if hasattr(self, "_parent"): return self._parent() else: return None @property def name(self): """The name under which this coordinate is stored in its parent container.""" if self.parent is None: return self.dim return next((name for name in self.parent if self.parent[name] is self), None) # --- dunders logic --- def __getitem__(self, item): if isinstance(item, slice): return self._slice(self._format_slice(item)) else: item = self._format_index(item) return Coordinate( self._get_value(item), None if np.ndim(item) == 0 else self.dim ) def __array__(self, dtype=None, copy=None): if self.empty: out = np.array([], dtype=self.dtype) else: out = self._get_value(self.indices) if dtype is not None: out = out.__array__(dtype) return out def __reduce__(self): return self.__class__, (self.data, self.dim) def __repr__(self): if self.empty: return "empty coordinate" elif len(self) == 1: return f"{self.tie_values[0]}" else: if np.issubdtype(self.dtype, np.floating): return f"{self.start:.3f} to {self.end:.3f}" elif np.issubdtype(self.dtype, np.datetime64): start_str = format_datetime(self.start) end_str = format_datetime(self.end) return f"{start_str} to {end_str}" else: return f"{self.start} to {self.end}" # --- queries ---
[docs] def isscalar(self): """Return ``True`` if this is a :class:`ScalarCoordinate`.""" return False
[docs] def isdim(self): """Return ``True`` if this coordinate is a dimensional coordinate.""" if self.parent is None or self.name is None: return None else: return self.parent.isdim(self.name)
[docs] def equals(self, other): """Return ``True`` if *other* is the same coordinate type with identical dim and data. Comparison is strict on dtype. Same type implies same ``data`` structure: either a single ``np.ndarray`` or a flat ``dict[str, np.ndarray]`` with the same keys. """ if type(self) is not type(other) or self.dim != other.dim: return False a, b = self.data, other.data if isinstance(a, dict): pairs = [(a[key], b[key]) for key in a] else: pairs = [(a, b)] for x, y in pairs: x, y = np.asarray(x), np.asarray(y) if x.dtype != y.dtype or not np.array_equal(x, y, equal_nan=False): return False return True
# --- selection / indexing ---
[docs] def to_index(self, item, method=None, endpoint=True): """ Convert a label-based selector to an integer index or slice. Parameters ---------- item : label, slice, or array-like Selector to resolve. method : {None, "nearest", "ffill", "bfill"}, optional How to resolve *item* when it does not match a label exactly. ``None`` (default) requires an exact match. ``"nearest"`` selects the closest label. ``"ffill"`` selects the last label ≤ *item*; ``"bfill"`` selects the first label ≥ *item*. Ignored when *item* is a slice. endpoint : bool, optional Whether to include the stop of a slice. Default ``True``. Returns ------- int, array of ints or slice """ if isinstance(item, slice): return self._slice_indexer(item.start, item.stop, item.step, endpoint) else: return self._get_indexer(item, method)
def _format_index(self, idx, bounds="raise"): """ Normalise integer index *idx*, handling negative indices and optional bounds checking. Parameters ---------- idx : int or array-like of int Index or indices to normalise. bounds : {"raise", "clip"}, optional ``"raise"`` (default) raises :exc:`IndexError` for out-of-bounds indices; ``"clip"`` clamps them to the valid range. Returns ------- numpy.ndarray Non-negative integer index array. """ idx = np.asarray(idx) if not np.issubdtype(idx.dtype, np.integer): raise IndexError("only integer are valid index") idx = idx + (idx < 0) * len(self) if bounds == "raise": if np.any(idx < 0) or np.any(idx >= len(self)): raise IndexError("index is out of bounds") elif bounds == "clip": idx = np.clip(idx, 0, len(self)) return idx def _format_slice(self, slc): """ Normalise *slc*, resolving ``None`` bounds, negative indices, and out-of-bounds. Parameters ---------- slc : slice Raw slice, as received from user code. Returns ------- slice Concrete ``slice(start, stop, step)`` with non-negative integer bounds clipped to ``[0, len(self)]``. """ start, stop, step = slc.indices(len(self)) if step < 0: raise NotImplementedError("negative slice step is not implemented") return slice(start, stop, step) def _slice_indexer(self, start=None, stop=None, step=None, endpoint=True): """ Return an integer :class:`slice` corresponding to the label range [*start*, *stop*]. Parameters ---------- start : label, optional First label to include (inclusive, via ``"bfill"`` look-up). stop : label, optional Last label to include (inclusive by default, via ``"ffill"`` look-up). step : not supported Reserved; raises :exc:`NotImplementedError` if provided. endpoint : bool, optional If ``True`` (default), include *stop* in the result. Returns ------- slice """ if start is not None: try: start_index = self._get_indexer(start, method="bfill") except KeyError: start_index = len(self) else: start_index = None if stop is not None: try: end_index = self._get_indexer(stop, method="ffill") stop_index = end_index + 1 except KeyError: stop_index = 0 else: stop_index = None if step is not None: raise NotImplementedError("cannot use step yet") if ( (not endpoint) and (stop is not None) and (self[stop_index - 1].values == stop) ): stop_index -= 1 return slice(start_index, stop_index) # --- routines ---
[docs] def copy(self, deep=True): """ Return a copy of this coordinate. Parameters ---------- deep : bool, optional If ``True`` (default) perform a deep copy; otherwise a shallow copy. Returns ------- Coordinate A new coordinate of the same subclass with copied data and metadata. """ if deep: func = deepcopy else: func = copy return self.__class__(func(self.data), func(self.dim), func(self.dtype))
[docs] def to_dataarray(self): """Convert this coordinate to a :class:`~xdas.DataArray` with a single dimension.""" from ..core.dataarray import DataArray # TODO: avoid defered import? if self.name is None: raise ValueError("cannot convert unnamed coordinate to DataArray") if self.parent is None: return DataArray( self.values, {self.dim: self}, dims=[self.dim], name=self.name, ) else: return DataArray( self.values, { name: coord for name, coord in self.parent.items() if coord.dim == self.dim }, dims=[self.dim], name=self.name, )
# --- IO --- @classmethod def _from_dataset(cls, dataset, name): """Read coordinates named *name* from an xarray *dataset* via each registered subclass.""" coords = {} for subcls in cls.__subclasses__(): coords |= subcls._collect_from_dataset(dataset, name) return coords # --- internals --- def _assign_parent(self, parent): """Attach this coordinate to its parent :class:`Coordinates` container.""" self._parent = weakref.ref(parent)
class SampledMixin(ABC): """ Shared behaviour for coordinates that carry sampled values along an axis. Mixed into the tie-point coordinate types (:class:`SampledCoordinate`, :class:`InterpCoordinate`). Both types describe a piecewise-monotonic axis composed of contiguous segments separated by *gaps* (the axis jumps forward by more than one sampling interval) or *overlaps* (the axis jumps backward, creating doubly-covered regions). This mixin provides the shared logic for detecting, cataloguing, and querying those discontinuities. """ @abstractmethod def get_sampling_interval(self, cast=True): """ Return the nominal sample spacing for this coordinate. Parameters ---------- cast : bool, optional If ``True`` (default), cast timedelta64 results to seconds (float). Returns ------- float or None ``None`` if the coordinate has fewer than two elements. """ @abstractmethod def get_split_indices(self, kind="discontinuities", tolerance=False): """ Return integer indices where this coordinate should be split. Each returned index ``i`` marks the start of a new segment: the boundary lies between element ``i - 1`` and element ``i``. The first segment always starts at index 0, so 0 is never included in the result. Parameters ---------- kind : {"discontinuities", "gaps", "overlaps"}, optional Which boundary type to return. ``"gaps"`` returns only boundaries where the axis jumps forward by more than one sampling interval; ``"overlaps"`` returns only boundaries where the axis jumps backward. ``"discontinuities"`` (default) returns both. tolerance : float, timedelta, None, or ``False``, optional Minimum absolute magnitude of the jump to report. Boundaries smaller than *tolerance* are silently dropped. ``None`` removes only zero-magnitude jumps (i.e. consecutive equal values). ``False`` (default) disables magnitude filtering and returns all boundaries of the requested kind. Returns ------- numpy.ndarray Integer indices of the start of each new segment (excluding the first). """ @abstractmethod def simplify(self, tolerance=None): """ Return a simplified copy of this coordinate with redundant tie points removed. Tie points whose removal would shift any label by no more than *tolerance* are dropped, reducing memory and I/O cost without meaningfully changing the represented axis. As a side effect, small gaps or overlaps that fall within *tolerance* may be absorbed, merging adjacent segments into one. Parameters ---------- tolerance : float, timedelta, None, or ``False``, optional Maximum allowed deviation from the original values. ``None`` uses zero tolerance (lossless). ``False`` returns an unchanged copy. Returns ------- Coordinate A new coordinate of the same subclass with fewer stored points. """ def get_discontinuities(self, tolerance=None): """ Return a DataFrame containing information about the discontinuities. Parameters ---------- tolerance : float, timedelta, or None, optional Minimum magnitude of a gap or overlap to include. ``None`` (default) reports all discontinuities regardless of size. Returns ------- pandas.DataFrame A DataFrame with the following columns: - start_index : int The index where the discontinuity starts. - end_index : int The index where the discontinuity ends. - start_value : float The value at the start of the discontinuity. - end_value : float The value at the end of the discontinuity. - delta : float The difference between the end_value and start_value. - type : str The type of the discontinuity, either "gap" or "overlap". """ if self.empty: return pd.DataFrame( columns=[ "start_index", "end_index", "start_value", "end_value", "delta", "type", ] ) indices = self.get_split_indices("discontinuities", tolerance) records = [] for index in indices: start_index = index end_index = index + 1 start_value = self._get_value(index) end_value = self._get_value(index + 1) delta = end_value - start_value if tolerance is not None and np.abs(delta) < tolerance: continue record = { "start_index": start_index, "end_index": end_index, "start_value": start_value, "end_value": end_value, "delta": delta, "type": ("gap" if end_value > start_value else "overlap"), } records.append(record) return pd.DataFrame.from_records(records) def get_availabilities(self): """ Return a DataFrame containing information about the data availability. Returns ------- pandas.DataFrame A DataFrame with the following columns: - start_index : int The index where the discontinuity starts. - end_index : int The index where the discontinuity ends. - start_value : float The value at the start of the discontinuity. - end_value : float The value at the end of the discontinuity. - delta : float The difference between the end_value and start_value. - type : str The type of the discontinuity, always "data". """ if self.empty: return pd.DataFrame( columns=[ "start_index", "end_index", "start_value", "end_value", "delta", "type", ] ) indices = np.concatenate([[0], self.get_split_indices(), [len(self)]]) records = [] for start_index, stop_index in pairwise(indices): end_index = stop_index - 1 start_value = self._get_value(start_index) end_value = self._get_value(end_index) records.append( { "start_index": start_index, "end_index": end_index, "start_value": start_value, "end_value": end_value, "delta": end_value - start_value, "type": "data", } ) return pd.DataFrame.from_records(records) def parse(data, dim=None): """ Normalise *data* / *dim* inputs accepted by coordinate constructors. Unpacks ``(dim, data)`` tuples and strips :class:`Coordinate` wrappers so that downstream constructors always receive a plain data object and an optional dimension string. Parameters ---------- data : array-like, Coordinate, or (dim, array-like) tuple Raw coordinate input. dim : str, optional Explicit dimension name; overrides any dimension carried by *data*. Returns ------- data : array-like Unwrapped data. dim : str or None Resolved dimension name. """ if isinstance(data, tuple): if dim is None: dim, data = data else: _, data = data if isinstance(data, Coordinate): if dim is None: dim = data.dim data = data.data return data, dim def parse_tolerance(tolerance, dtype): """ Normalise *tolerance* to the correct type for *dtype*. Converts ``None`` to zero, and for datetime64 dtypes converts a numeric tolerance (in seconds) to the appropriate :class:`numpy.timedelta64`. Parameters ---------- tolerance : float or None Raw tolerance value. dtype : numpy.dtype The dtype of the coordinate values the tolerance will be compared against. Returns ------- tolerance : int, float, or numpy.timedelta64 """ if np.issubdtype(dtype, np.datetime64): if tolerance is None: tolerance = np.timedelta64(0) elif isinstance(tolerance, (int, float)): tolerance = np.timedelta64(round(tolerance * 1e9), "ns") else: if tolerance is None: tolerance = 0 return tolerance def get_sampling_interval(da, dim, cast=True): """ Return the sample spacing along a given dimension. Parameters ---------- da : DataArray The data from which extract the sample spacing. dim : str The dimension along which get the sample spacing. cast: bool, optional Whether to cast datetime64 to seconds, by default True. Returns ------- float The sample spacing. """ return da[dim].get_sampling_interval(cast=cast) def isscalar(data): """Return ``True`` if *data* converts to a 0-d non-object numpy array.""" data = np.asarray(data) return (data.dtype != np.dtype(object)) and (data.ndim == 0) def is_monotonic_increasing(x): """Return ``True`` if every element of *x* is strictly greater than the previous one.""" zero = np.timedelta64(0) if np.issubdtype(x.dtype, np.datetime64) else 0 return np.all(np.diff(x) > zero) def format_datetime(x): """Format a datetime64-like *x* as an ISO string, truncating sub-millisecond digits.""" string = str(x) if "." in string: datetime, digits = string.split(".") digits = digits[:3] return ".".join([datetime, digits]) else: return string