Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 270 additions & 2 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

import abc
import numbers
from collections.abc import Sized
from contextlib import suppress
from contextlib import contextmanager, suppress
from functools import cache
from operator import gt, lt
from typing import Any, TypeVar
Expand All @@ -23,8 +24,9 @@
import dascore as dc
from dascore.compat import array, is_array
from dascore.constants import _AGG_FUNCS, DIM_REDUCE_DOCS, dascore_styles
from dascore.exceptions import CoordError, ParameterError
from dascore.exceptions import CoordError, ParameterError, UnitError
from dascore.units import (
PintError,
Quantity,
Unit,
convert_units,
Expand Down Expand Up @@ -58,6 +60,112 @@

step_type = TypeVar("step_type")

# Types which coords know how to combine with in array operations.
_ARRAY_OP_TYPES = (np.ndarray, np.generic, numbers.Number, Quantity, list, tuple)
Comment on lines +63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Pint Unit operands in coordinate arithmetic

The supported operand types include Pint Quantity but omit Pint Unit, even though DASCore exposes get_unit and commonly forms quantities with expressions such as array * get_unit("m"). As a result, coord * get_unit("s") defers to Pint and either returns a bare Quantity or fails rather than returning a coordinate with combined units, contrary to the new array-like arithmetic contract. Pint Unit operands should be handled explicitly like other unit-bearing operands.

Useful? React with 👍 / 👎.


# Ufuncs whose operands must all share the same units. These also return
# outputs in those units, unless the output is boolean.
_UNIT_MATCHED_UFUNCS = frozenset(
{
np.add,
np.subtract,
np.mod,
np.fmod,
np.remainder,
np.maximum,
np.minimum,
np.fmax,
np.fmin,
np.hypot,
np.greater,
np.greater_equal,
np.less,
np.less_equal,
np.equal,
np.not_equal,
np.positive,
np.negative,
np.absolute,
np.fabs,
np.rint,
np.floor,
np.ceil,
np.trunc,
}
)

# Numpy functions which reduce an array to a single value. Time-like coords
# need special handling for these (see _reduce_time_like).
_REDUCING_ARRAY_FUNCS = frozenset(
{
np.mean,
np.nanmean,
np.median,
np.nanmedian,
np.std,
np.nanstd,
np.sum,
np.nansum,
np.min,
np.nanmin,
np.max,
np.nanmax,
}
Comment on lines +115 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route np.average through time-aware reduction

np.average is absent from the time-like reduction set, so np.average(datetime_coord) bypasses _reduce_time_like and invokes NumPy directly on the raw datetime64 array. NumPy cannot average absolute datetimes through its ordinary add/divide reduction, so this common mean operation raises even though the equivalent np.mean(datetime_coord) is handled. Include np.average in the time-aware path, including its weights handling.

Useful? React with 👍 / 👎.

)


def _map_nested(func, obj):
"""Apply func to each non-container element of a nested structure."""
if isinstance(obj, tuple | list):
return type(obj)(_map_nested(func, x) for x in obj)
if isinstance(obj, dict):
return {i: _map_nested(func, v) for i, v in obj.items()}
return func(obj)


@contextmanager
def _unit_error_context(func, units):
"""Raise a dascore UnitError when pint can't perform an operation."""
try:
yield
except PintError as ex:
name = getattr(func, "__name__", func)
msg = f"{name} failed for coordinate with units of {units}. {ex}"
raise UnitError(msg) from ex


def _to_magnitude(obj, units=None):
"""Strip units from a quantity, first converting to units if provided."""
if not isinstance(obj, Quantity):
return obj
return obj.magnitude if units is None else obj.to(units).magnitude


def _wrap_array_op_output(out, units=None):
"""
Convert the output of an array operation back to a coordinate.

Scalars (eg reductions) keep their units but are not coordinates, and
boolean arrays are left alone since they are masks, not coordinates.
"""
if isinstance(out, tuple | list): # Eg np.divmod or np.array_split.
return type(out)(_wrap_array_op_output(x, units) for x in out)
if isinstance(out, Quantity):
# Dimensionless units can still have a scale (eg m/cm) so the
# magnitude has to be converted before the units are dropped.
if out.units.dimensionless:
out, units = out.to("dimensionless").magnitude, None
Comment on lines +162 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve explicitly dimensionless coordinate units

This treats every dimensionless Pint unit as a canceled unit and normalizes it away, including meaningful units already attached to the coordinate such as percent or radian. For example, adding 1 to a coordinate whose value is 50 with units percent produces the unitless value 0.51 rather than a coordinate containing 51 percent. Only dimensionless units produced by cancellation should be normalized and dropped; unit-preserving operations must retain the coordinate's original dimensionless unit and magnitude scale.

Useful? React with 👍 / 👎.

else:
out, units = out.magnitude, out.units
if not is_array(out) or np.ndim(out) == 0:
# Time-like values (eg datetime64) can't have units attached.
if units is None or dtype_time_like(np.asarray(out).dtype):
return out
return out * units
if np.issubdtype(out.dtype, np.bool_):
return out
return get_coord(data=out, units=units)


def ensure_consistent_dtype(value, name, dtype):
"""Ensure the values are consistent with dtype."""
Expand Down Expand Up @@ -247,6 +355,14 @@ class BaseCoord(DascoreBaseModel, abc.ABC):
Coordinates should usually be created with
[get_coords](`dascore.core.coords.get_coord`) rather than using the class
directly.

Notes
-----
Coordinates support python operators, numpy ufuncs, and numpy functions,
each of which returns a new coordinate whose units reflect the operation
performed. Operations which return a single value (eg `np.mean`) return
a scalar and operations which return booleans (eg `np.greater`) return
arrays. See the [coordinate tutorial](/tutorial/coords.qmd) for details.
"""

units: UnitQuantity = None
Expand Down Expand Up @@ -487,6 +603,158 @@ def __array__(self, dtype=None, copy=False):
"""Numpy method for getting array data with `np.array(coord)`."""
return self.data

def _to_operand(self, obj, units=None):
"""
Convert an input of an array operation to an array or quantity.

Parameters
----------
obj
The object to convert. Coords and quantities are unpacked into
their values and units, anything else is passed through.
units
If provided, the units assumed for values which have none (eg
the 1 in `coord + 1`).
"""
if isinstance(obj, BaseCoord):
data, obj_units = obj.data, obj.units
elif isinstance(obj, Quantity):
data, obj_units = obj.magnitude, obj.units
else:
data, obj_units = obj, None
# Time-like coords operate on raw values; pint knows nothing of
# numpy's datetime64/timedelta64.
if dtype_time_like(self.dtype):
return data
Comment on lines +631 to +634

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert quantity operands before time-like operations

For a time-like coordinate, this early return discards every operand's Pint units and forwards only its magnitude to NumPy. On a datetime64[ns] coordinate, coord + 1 * second is therefore evaluated as datetime_array + 1 and advances by one nanosecond rather than one second; an incompatible quantity such as 1 * meter can likewise be interpreted as a raw integer instead of raising UnitError. Quantity operands must be converted to an appropriate timedelta64 or rejected before taking the raw time-like path.

Useful? React with 👍 / 👎.

obj_units = obj_units if obj_units is not None else units
return data if obj_units is None else data * obj_units

def _get_op_units(self, inputs):
"""Get the units which apply to operands which have none."""
if self.units is not None:
return self.units
others = (getattr(x, "units", None) for x in inputs)
return next((x for x in others if x is not None), None)

def _operate(self, ufunc, *inputs):
"""Apply a ufunc, deferring to other types when they aren't known."""
if any(not isinstance(x, (BaseCoord, *_ARRAY_OP_TYPES)) for x in inputs):
return NotImplemented
return ufunc(*inputs)

def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
"""Implement numpy's ufunc protocol (eg np.sqrt(coord), coord + 1)."""
# Coord data is read-only, so numpy raises for operations which
# write into it, but ufunc.at ignores the flag and must be rejected.
if method == "at":
msg = (
"Coordinates are immutable, so ufunc.at is not supported. "
"Apply the operation to the coordinate's values instead."
)
raise ParameterError(msg)
if any(not isinstance(x, (BaseCoord, *_ARRAY_OP_TYPES)) for x in inputs):
return NotImplemented
# Ufuncs which require operands share units also preserve them.
matched = ufunc in _UNIT_MATCHED_UFUNCS
units = self._get_op_units(inputs)
operands = [self._to_operand(x, units if matched else None) for x in inputs]
with _unit_error_context(ufunc, self.unit_str):
if method != "__call__":
# Pint doesn't implement reduce/accumulate/outer, so units
# are handled here, which only works if they don't change.
if not matched and units is not None:
msg = (
f"The units resulting from {ufunc.__name__}.{method} "
f"are ambiguous for a coordinate with units of "
f"{self.unit_str}. Use the coordinate's values instead."
)
raise UnitError(msg)
operands = [_to_magnitude(x, units) for x in operands]
# When operands are quantities pint performs the unit algebra
# (eg m * m -> m ** 2) and raises on invalid ops (eg m + s).
out = getattr(ufunc, method)(*operands, **kwargs)
Comment on lines +661 to +675

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject the mutating at ufunc method

Although out= is rejected above to enforce immutability, np.add.at(unitless_coord, [0], 1) reaches this call with the coordinate replaced by its internal writable array and mutates it in place. This can also leave cached values such as min() and max() stale. Reject the at method (or operate on a copy and return a new coordinate) before invoking the ufunc.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, at does reach the internal array. It is now rejected alongside out= with a ParameterError, covered by test_mutating_operations_raise.

Comment on lines +676 to +679

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert quantity-valued ufunc reduction keywords

For ufunc methods, only positional operands are converted to shared magnitudes, while unit-bearing keyword operands are passed through unchanged. Thus a compatible call such as np.add.reduce(meter_coord, initial=100 * cm) sends a centimeter Quantity into a numeric reduction over meter magnitudes and raises instead of converting the initial value to 1 meter. Quantity-valued method keywords such as initial need conversion alongside the positional operands.

Useful? React with 👍 / 👎.

return _wrap_array_op_output(out, units if matched else None)

def __array_function__(self, func, types, args, kwargs):
"""Implement numpy's array protocol (eg np.concatenate([coord1]))."""
if any(not issubclass(x, (BaseCoord, *_ARRAY_OP_TYPES)) for x in types):
return NotImplemented
# Numpy can't reduce absolute times so dascore's logic is used.
if dtype_time_like(self.dtype) and func in _REDUCING_ARRAY_FUNCS:
out = _reduce_time_like(func, self.data)
return _wrap_array_op_output(out[0] if out.size == 1 else out, self.units)
Comment on lines +693 to +695

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward reduction arguments for time-like coordinates

For time-like coordinates, this branch discards every argument except the input data. Consequently calls such as np.mean(coord, where=mask) ignore the mask, and reductions on multidimensional coordinates with axis=... reduce the entire array; keepdims=True is also ignored. The time-aware reduction path must preserve applicable positional and keyword reduction arguments.

Useful? React with 👍 / 👎.

args = _map_nested(self._to_operand, args)
kwargs = _map_nested(self._to_operand, kwargs)
Comment on lines +696 to +697

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply coordinate units to unitless array-function operands

When a NumPy array function requires compatible units, such as np.clip(meter_coord, 3, 7) or np.concatenate([meter_coord, raw_values]), these calls invoke _to_operand without the coordinate's units. The raw bounds/values therefore remain dimensionless while the coordinate becomes a Pint quantity, causing Pint to reject the operation instead of treating the raw values as meters as promised for unit-matched operations. Array-function arguments need the same shared-unit handling used for matched ufuncs.

Useful? React with 👍 / 👎.

with _unit_error_context(func, self.unit_str):
out = func(*args, **kwargs)
Comment on lines +686 to +689

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject mutating NumPy array functions

For a unitless coordinate, _map_nested(self._to_operand, args) exposes the coordinate's internal array directly to array functions. A destination-style call such as np.copyto(coord, 0) therefore mutates the supposedly immutable coordinate and can invalidate cached metadata, despite the explicit immutability protection in __array_ufunc__. Mutating array functions and destination arguments need to be rejected or supplied copies.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. __array_function__ rejects a set of known destination-style functions (copyto, place, put, put_along_axis, putmask, fill_diagonal) and any call passing out=, both with a ParameterError.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier reply: I checked this one empirically and the premise is wrong. Coord arrays are read-only (dascore.compat.array clears the writeable flag), and numpy honors that for np.copyto, __setitem__, and out= — all three raise ValueError: assignment destination is read-only. So there was no mutation hole here; the guard only replaces that ValueError with a ParameterError explaining why.

The at case (the sibling comment) is different and genuinely needed: np.add.at ignores the read-only flag entirely on numpy 2.4.6, so it really did mutate the coordinate. Comments in the code now say which is which.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up: rather than keep a guard that duplicates protection numpy already provides, the custom rejection is removed. np.copyto, np.put, np.putmask, np.place, and out= all raise numpy's own ValueError: assignment destination is read-only against a coordinate, which test_operations_writing_to_coord_raise now pins. Only ufunc.at keeps an explicit ParameterError, because that is the one case where numpy ignores the flag.

Comment on lines +686 to +689

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route datetime reductions through the time-aware reducer

For a datetime coordinate, _to_operand supplies the raw datetime64 array and this invokes functions such as np.mean directly, but NumPy cannot sum absolute datetimes to calculate their mean. The existing reduce_coord path deliberately uses _reduce_time_like for this case, so the newly advertised np.mean(datetime_coord) fails even though the same operation works through the coordinate reduction API. Apply the existing time-aware reduction logic when dispatching these NumPy reducers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed. Time-like coords are routed through the existing _reduce_time_like for the reducing numpy functions, so np.mean(time_coord) returns a datetime64. This also surfaced a related bug: a time-like scalar result had self.units multiplied onto it, which raised a UFuncTypeError. Test: test_time_coord_reductions.

return _wrap_array_op_output(out)

def __add__(self, other):
return self._operate(np.add, self, other)

def __radd__(self, other):
return self._operate(np.add, other, self)

def __sub__(self, other):
return self._operate(np.subtract, self, other)

def __rsub__(self, other):
return self._operate(np.subtract, other, self)

def __mul__(self, other):
return self._operate(np.multiply, self, other)

def __rmul__(self, other):
return self._operate(np.multiply, other, self)

def __truediv__(self, other):
return self._operate(np.divide, self, other)

def __rtruediv__(self, other):
return self._operate(np.divide, other, self)

def __floordiv__(self, other):
return self._operate(np.floor_divide, self, other)

def __rfloordiv__(self, other):
return self._operate(np.floor_divide, other, self)

def __mod__(self, other):
return self._operate(np.mod, self, other)

def __rmod__(self, other):
return self._operate(np.mod, other, self)

def __pow__(self, other):
return self._operate(np.power, self, other)

def __rpow__(self, other):
return self._operate(np.power, other, self)

def __neg__(self):
return self._operate(np.negative, self)

def __pos__(self):
return self._operate(np.positive, self)

def __abs__(self):
return self._operate(np.absolute, self)
Comment on lines +740 to +741

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add ordering operator dunders

The matched-ufunc set includes greater, greater_equal, less, and less_equal, but the new Python operator block never forwards the corresponding ordering dunders. As a result, coord > 4 * get_quantity("m") raises TypeError instead of producing the same mask as np.greater(coord, ...), even though coordinates are documented as supporting Python operators and Patch already exposes these comparisons. Add __gt__, __ge__, __lt__, and __le__ forwarding to the registered ufuncs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added __gt__, __ge__, __lt__, and __le__. __eq__/__ne__ are deliberately left alone: coords are pydantic models and their equality compares coordinates (used in align, spool equality, etc.), not values. There is a comment in the code noting this.


# Note: __eq__ (and __ne__) are not defined here; they compare
# coordinates, not their values, since coords are pydantic models.

def __gt__(self, other):
return self._operate(np.greater, self, other)

def __ge__(self, other):
return self._operate(np.greater_equal, self, other)

def __lt__(self, other):
return self._operate(np.less, self, other)

def __le__(self, other):
return self._operate(np.less_equal, self, other)

@cached_method
def min(self):
"""Return min value."""
Expand Down
3 changes: 3 additions & 0 deletions dascore/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
import pandas as pd
import pint
from pint import DimensionalityError, Quantity, UndefinedUnitError, Unit

# Re-exported so dascore code can catch any error raised by pint.
from pint.errors import PintError # noqa: F401
from platformdirs import user_cache_path

import dascore as dc
Expand Down
49 changes: 49 additions & 0 deletions docs/tutorial/coords.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,55 @@ assert coord.get_next_index(1) == 1
assert coord.get_next_index(2.000001) == 3
```

## Coordinate Arithmetic

Coordinates behave much like numpy arrays; python operators, numpy ufuncs, and
numpy functions all work on them and return new coordinates. Units are tracked
through the operation, so multiplying two coordinates with units of `m` returns
a coordinate with units of `m**2`.

```{python}
import numpy as np

from dascore.core import get_coord

coord = get_coord(data=np.array([1., 4., 9.]), units="m")

# Values without units are assumed to be in the coordinate's units.
assert (coord + 1).units == coord.units

# But units are otherwise tracked through the operation.
assert (coord * coord).unit_str == "m ** 2"
assert np.sqrt(coord).unit_str == "m ** 0.5"

# Coordinates (or quantities) with other units are first converted.
from dascore.units import get_quantity

assert np.allclose((coord + 100 * get_quantity("cm")).values, coord.values + 1)
```

Two exceptions to the "operations return coordinates" rule keep results useful:
operations which return a single value (eg `np.mean`) return a scalar, and
operations which return booleans (eg `np.greater`) return arrays which can be
used to index other arrays.

```{python}
# Reductions return a scalar (with units, when the coord has them).
print(np.mean(coord))

# Comparisons return boolean arrays.
print(np.greater(coord, 2 * get_quantity("m")))
```

Comparison operators work as well, so `coord > 2 * get_quantity("m")` is the
same as the call above.

Operations which don't make sense for the coordinate's units, such as adding
seconds to meters, raise a [`UnitError`](`dascore.exceptions.UnitError`). So do
ufunc methods whose units can't be determined, such as `np.multiply.reduce`.
Coordinate values are read-only, so numpy raises a `ValueError` for operations
which would write into them.

# CoordManager

The [`CoordManager`](`dascore.core.coordmanager.CoordManager`) handles a group of coordinates and provides methods for updating managed data arrays.
Expand Down
Loading
Loading