diff --git a/packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py b/packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py index 8051d8ec..b8ebfef9 100644 --- a/packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py +++ b/packages/unxts.interop.xarray/src/unxts/interop/xarray/_src/conversion.py @@ -260,6 +260,31 @@ def extract_units(obj: object, /) -> dict: raise TypeError(msg) +def _reject_unknown_unit_names( + units: "Mapping[Hashable, Any]", valid_names: "set[Any]", obj_kind: str +) -> None: + """Raise if units names something that is not a variable/coordinate. + + Without this, a typo in a **unit_kwargs name (e.g. + quantify(temperatrue="K")) is silently dropped by units.get(name), + leaving the data unquantified with no error or warning. + """ + # Sort so the message is deterministic: `units` insertion order follows the + # accessor's ``set(...)`` iteration, which is not stable across processes. + unknown = sorted(repr(k) for k in units if k not in valid_names) + if not unknown: + return + # ``None`` is the DataArray's own-data key; render it explicitly so the + # message stays helpful (and non-empty) when there are no other names. + names = ("None (the data)" if n is None else repr(n) for n in valid_names) + valid = ", ".join(sorted(names)) or "(none)" + msg = ( + f"got unit(s) for name(s) not in the {obj_kind}: {', '.join(unknown)}. " + f"Valid names: {valid}." + ) + raise ValueError(msg) + + @dispatch def attach_units( obj: DataArray, units: Mapping[Hashable, u.AbstractUnit | str | None] @@ -296,6 +321,8 @@ def attach_units( {} """ + _reject_unknown_unit_names(units, {None} | set(obj.coords), "DataArray") + # Handle the data array itself (None key = the DataArray's own data) data_unit = units.get(None) new_data = _array_attach_units(obj.data, data_unit) @@ -346,6 +373,8 @@ def attach_units( New Dataset with units attached as Quantities. """ + _reject_unknown_unit_names(units, set(obj.data_vars) | set(obj.coords), "Dataset") + # Handle all variables in dataset new_vars = {} for name, var in obj.data_vars.items(): diff --git a/packages/unxts.interop.xarray/tests/test_unknown_unit_keys.py b/packages/unxts.interop.xarray/tests/test_unknown_unit_keys.py new file mode 100644 index 00000000..b39ef8ae --- /dev/null +++ b/packages/unxts.interop.xarray/tests/test_unknown_unit_keys.py @@ -0,0 +1,34 @@ +"""quantify()/attach_units reject unit specs for names that don't exist. + +Regression: a typo in a `**unit_kwargs` name was silently dropped, leaving the +data unquantified with no error. +""" + +import pytest +import unxts.interop.xarray # noqa: F401 # registers the .unxt accessor +import xarray as xr + +import unxt as u + + +def test_dataset_typo_key_raises(): + ds = xr.Dataset({"temperature": ("x", [1.0, 2.0])}) + with pytest.raises(ValueError, match=r"not in the Dataset.*temperatrue"): + ds.unxt.quantify(temperatrue="K") + # the correct name still works + q = ds.unxt.quantify(temperature="K") + assert u.unit_of(q["temperature"].data) == u.unit("K") + + +def test_dataarray_typo_coord_key_raises(): + # ``y`` is a *non-dimension* coordinate on dim ``x``. A dimension coordinate + # would be wrapped in a pandas index by xarray, which drops the unit (a + # separate, known limitation), so a non-dimension coord is used here to + # verify that a valid coord name actually quantifies. + da = xr.DataArray([1.0, 2.0], dims=["x"], coords={"y": ("x", [10.0, 20.0])}) + with pytest.raises(ValueError, match=r"not in the DataArray.*'yy'"): + da.unxt.quantify(yy="s") + # valid data + coord names still quantify + q = da.unxt.quantify("m", y="s") + assert u.unit_of(q.data) == u.unit("m") + assert u.unit_of(q.coords["y"].data) == u.unit("s")