xarray-annotated 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xarray_annotated/__init__.py +50 -0
- xarray_annotated/_annotations.py +119 -0
- xarray_annotated/_config.py +101 -0
- xarray_annotated/_reader.py +110 -0
- xarray_annotated/_writer.py +99 -0
- xarray_annotated/py.typed +0 -0
- xarray_annotated/schema/__init__.py +62 -0
- xarray_annotated/schema/_annotations.py +254 -0
- xarray_annotated/schema/_check.py +232 -0
- xarray_annotated/schema/_config.py +147 -0
- xarray_annotated/schema/_decorator.py +124 -0
- xarray_annotated/temporal/__init__.py +60 -0
- xarray_annotated/temporal/_annotations.py +154 -0
- xarray_annotated/temporal/_check.py +326 -0
- xarray_annotated/temporal/_config.py +182 -0
- xarray_annotated/temporal/_decorator.py +133 -0
- xarray_annotated/units/__init__.py +53 -0
- xarray_annotated/units/_annotations.py +169 -0
- xarray_annotated/units/_check.py +592 -0
- xarray_annotated/units/_config.py +237 -0
- xarray_annotated/units/_decorator.py +203 -0
- xarray_annotated/units/_registry.py +94 -0
- xarray_annotated-0.5.2.dist-info/METADATA +156 -0
- xarray_annotated-0.5.2.dist-info/RECORD +26 -0
- xarray_annotated-0.5.2.dist-info/WHEEL +4 -0
- xarray_annotated-0.5.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""Runtime unit validation and conversion.
|
|
2
|
+
|
|
3
|
+
The "usage side" of the package: given a validation `Policy` and a declared unit
|
|
4
|
+
string (typically read off a signature by `_annotations`), validate an actual
|
|
5
|
+
`DataArray` against it and convert it into the declared unit. Conversion is
|
|
6
|
+
delegated to pint-xarray's `.pint` accessor via the active registry.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
import pint
|
|
12
|
+
import xarray as xr
|
|
13
|
+
from pint_xarray.errors import PintExceptionGroup
|
|
14
|
+
|
|
15
|
+
from ._annotations import Unit
|
|
16
|
+
from ._config import (
|
|
17
|
+
OnInexact,
|
|
18
|
+
OnMissing,
|
|
19
|
+
OnOutput,
|
|
20
|
+
_validate_on_inexact,
|
|
21
|
+
_validate_on_missing,
|
|
22
|
+
_validate_on_output,
|
|
23
|
+
get_policy,
|
|
24
|
+
)
|
|
25
|
+
from ._registry import _cf_hint, get_registry
|
|
26
|
+
|
|
27
|
+
#: Upper bound on the length of a unit string we are willing to hand to pint's
|
|
28
|
+
#: parser. Real units are short (``"umol m-2 s-1"`` is 12 chars); anything far
|
|
29
|
+
#: longer is not a unit. pint's parser is not hardened against adversarial input
|
|
30
|
+
#: and can hang on pathological strings (e.g. a single very long token), so an
|
|
31
|
+
#: over-long ``units`` attribute read from an untrusted file must be refused
|
|
32
|
+
#: *before* it reaches the parser rather than after. This does not defend against
|
|
33
|
+
#: every DoS input pint accepts (a short string can still be pathological, e.g.
|
|
34
|
+
#: nested numeric exponentiation), only the length-driven class.
|
|
35
|
+
_MAX_UNIT_LEN = 256
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UnitsWarning(UserWarning):
|
|
39
|
+
"""Emitted when a DataArray input cannot be fully unit-validated.
|
|
40
|
+
|
|
41
|
+
Raised by `check_units` when an input has a missing or unparseable `units`
|
|
42
|
+
attribute and `on_missing` is `"warn"`, or when a value-changing conversion
|
|
43
|
+
happens under `on_inexact="warn"`. Subclasses `UserWarning` so callers can
|
|
44
|
+
target it specifically.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> import warnings
|
|
48
|
+
>>> from xarray_annotated.units import UnitsWarning
|
|
49
|
+
>>> issubclass(UnitsWarning, UserWarning)
|
|
50
|
+
True
|
|
51
|
+
>>> warnings.filterwarnings("error", category=UnitsWarning)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _quantified_label(unit: pint.Unit) -> str:
|
|
56
|
+
"""Render a quantified array's own pint unit as a unit string.
|
|
57
|
+
|
|
58
|
+
Compact-pretty (`"hPa"`, `"µmol/m²/s"`, `"°C"`) rather than pint's canonical
|
|
59
|
+
long form, because this string is what users see in error messages. It
|
|
60
|
+
round-trips through the registry -- pint's parser accepts the Unicode
|
|
61
|
+
superscripts and `µ` it emits -- so it can be compared and re-parsed like
|
|
62
|
+
any label read from `attrs`.
|
|
63
|
+
|
|
64
|
+
Going back through a *string* rather than passing the `pint.Unit` object on
|
|
65
|
+
is deliberate: it keeps every comparison inside this package's registry,
|
|
66
|
+
even when the array was quantified under a different one (quantities from
|
|
67
|
+
two registries cannot be mixed). A unit that is undefined in the active
|
|
68
|
+
registry then fails to parse and is routed through the usual `on_missing`
|
|
69
|
+
path, exactly like any other unreadable label.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
unit: The `pint.Unit` taken from `da.pint.units`.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The unit as a compact-pretty string.
|
|
76
|
+
"""
|
|
77
|
+
return f"{unit:~P}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def assert_valid_unit(unit: str | Unit, context: str) -> None:
|
|
81
|
+
"""Raise `ValueError` if `unit` is not parseable by the active registry.
|
|
82
|
+
|
|
83
|
+
Used to fail fast at declaration time: a malformed or undefined unit string
|
|
84
|
+
(a typo such as `"degrees_C"`, or `"not_a_unit"`) is rejected as soon as it
|
|
85
|
+
is declared, rather than only when it is later used to validate data.
|
|
86
|
+
|
|
87
|
+
Accepts either a bare unit string or a `Unit` marker (its `.unit` string is
|
|
88
|
+
validated), so a consumer that read a `Unit` off an annotation can pass it
|
|
89
|
+
straight through.
|
|
90
|
+
|
|
91
|
+
The registry raises a variety of exception types for bad input
|
|
92
|
+
(`pint.UndefinedUnitError`, `AssertionError`, …); all are caught and
|
|
93
|
+
re-raised as a single, clear `ValueError`.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
unit: The unit string (or `Unit` marker) to validate.
|
|
97
|
+
context: Names the offending site (e.g. `"mymodel input 'vpd_weekly'"`)
|
|
98
|
+
for the error message.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If `unit` cannot be parsed by the active registry.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
>>> from xarray_annotated.units import assert_valid_unit
|
|
105
|
+
>>> assert_valid_unit("Pa", "test") # no raise
|
|
106
|
+
>>> assert_valid_unit("degC", "test") # no raise
|
|
107
|
+
"""
|
|
108
|
+
if isinstance(unit, Unit):
|
|
109
|
+
unit = unit.unit
|
|
110
|
+
if len(unit) > _MAX_UNIT_LEN:
|
|
111
|
+
# Refuse before parsing: an over-long unit string can hang pint's parser.
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{context}: declared unit is over-long "
|
|
114
|
+
f"({len(unit)} chars > {_MAX_UNIT_LEN}); refusing to parse"
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
get_registry().Unit(unit)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
# The registry raises several error types for bad input; normalise them.
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"{context}: declared unit {unit!r} is not a recognised "
|
|
122
|
+
f"unit ({type(exc).__name__}: {exc}){_cf_hint(unit)}"
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def units_compatible(a: str | Unit, b: str | Unit) -> bool:
|
|
127
|
+
"""Return whether two units are *dimensionally* compatible.
|
|
128
|
+
|
|
129
|
+
Mirrors the runtime conversion semantics of `check_units`: `"hPa"` and
|
|
130
|
+
`"Pa"` are compatible (one converts to the other), whereas `"Pa"` and
|
|
131
|
+
`"kg"` are not. Both are assumed already validated by `assert_valid_unit`.
|
|
132
|
+
|
|
133
|
+
Accepts either a bare unit string or a `Unit` marker on each side (its
|
|
134
|
+
`.unit` string is used), so a consumer holding markers — e.g. `Declared`
|
|
135
|
+
values from `declarations_from_signature` — can compare them directly.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
a: A unit string or `Unit` marker.
|
|
139
|
+
b: Another unit string or `Unit` marker.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
`True` if the units are dimensionally compatible.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
>>> from xarray_annotated.units import Unit, units_compatible
|
|
146
|
+
>>> units_compatible("hPa", "Pa")
|
|
147
|
+
True
|
|
148
|
+
>>> units_compatible(Unit("hPa"), "Pa") # markers accepted too
|
|
149
|
+
True
|
|
150
|
+
>>> units_compatible("Pa", "kg")
|
|
151
|
+
False
|
|
152
|
+
"""
|
|
153
|
+
a = a.unit if isinstance(a, Unit) else a
|
|
154
|
+
b = b.unit if isinstance(b, Unit) else b
|
|
155
|
+
return get_registry().Unit(a).is_compatible_with(get_registry().Unit(b))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def units_equal(a: str | Unit, b: str | Unit) -> bool:
|
|
159
|
+
"""Return whether two units are the *same* unit (no conversion needed).
|
|
160
|
+
|
|
161
|
+
Compares the parsed units, so different spellings of the same unit are equal
|
|
162
|
+
(`"Pa"` == `"pascal"`, `"1"` == `"dimensionless"`) while a prefixed unit
|
|
163
|
+
differs (`"hPa"` != `"Pa"`). This is the distinction the `on_inexact`
|
|
164
|
+
axis turns on: a value-changing conversion is one where the units are
|
|
165
|
+
compatible but *not* equal; equivalent spellings imply no value change.
|
|
166
|
+
|
|
167
|
+
Accepts either a bare unit string or a `Unit` marker on each side (its
|
|
168
|
+
`.unit` string is used), matching `units_compatible`.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
a: A unit string or `Unit` marker.
|
|
172
|
+
b: Another unit string or `Unit` marker.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
`True` if the units are the same (no conversion needed).
|
|
176
|
+
|
|
177
|
+
Examples:
|
|
178
|
+
>>> from xarray_annotated.units import Unit, units_equal
|
|
179
|
+
>>> units_equal("Pa", "pascal")
|
|
180
|
+
True
|
|
181
|
+
>>> units_equal(Unit("Pa"), Unit("pascal")) # markers accepted too
|
|
182
|
+
True
|
|
183
|
+
>>> units_equal("hPa", "Pa")
|
|
184
|
+
False
|
|
185
|
+
"""
|
|
186
|
+
a = a.unit if isinstance(a, Unit) else a
|
|
187
|
+
b = b.unit if isinstance(b, Unit) else b
|
|
188
|
+
return get_registry().Unit(a) == get_registry().Unit(b)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_units(
|
|
192
|
+
da: xr.DataArray,
|
|
193
|
+
declared: str,
|
|
194
|
+
name: str,
|
|
195
|
+
on_missing: OnMissing | None = None,
|
|
196
|
+
on_inexact: OnInexact | None = None,
|
|
197
|
+
qualname: str | None = None,
|
|
198
|
+
) -> xr.DataArray:
|
|
199
|
+
"""Validate and convert an input `DataArray` to its declared unit.
|
|
200
|
+
|
|
201
|
+
Returns a `DataArray` whose data is expressed in `declared` and whose
|
|
202
|
+
`units` attribute equals `declared`.
|
|
203
|
+
|
|
204
|
+
Behaviour follows the active `Policy` (`get_policy`); `on_missing` and
|
|
205
|
+
`on_inexact` override their axes for this call when given (`None` defers to
|
|
206
|
+
the policy). If the policy is disabled the array is returned unchanged.
|
|
207
|
+
|
|
208
|
+
Three events are handled, each building on the one before:
|
|
209
|
+
|
|
210
|
+
* **No parseable unit** — the input has no `units` attribute, or one the
|
|
211
|
+
registry cannot parse (e.g. a non-CF string like `"fraction"`): follows
|
|
212
|
+
`on_missing` (`"error"` raises, `"warn"` warns and returns unchanged,
|
|
213
|
+
`"ignore"` returns unchanged silently).
|
|
214
|
+
* **Value-changing conversion** — the unit is dimensionally compatible with
|
|
215
|
+
`declared` but not the same unit (e.g. `"hPa"` where `"Pa"` is
|
|
216
|
+
declared): follows `on_inexact` (`"error"` raises, `"warn"` warns then
|
|
217
|
+
converts, `"convert"` converts silently). Equivalent spellings
|
|
218
|
+
(`"pascal"` for `"Pa"`) imply no value change and are simply relabelled,
|
|
219
|
+
without touching the data.
|
|
220
|
+
* **Dimensional mismatch** — two parseable but incompatible units (e.g. a
|
|
221
|
+
mass where a pressure is declared): always raises
|
|
222
|
+
`pint.DimensionalityError`, regardless of policy.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
da: The input `DataArray` to validate and convert.
|
|
226
|
+
declared: The expected unit string (e.g. `"Pa"`).
|
|
227
|
+
name: A label for the array, used in error/warning messages.
|
|
228
|
+
on_missing: Override the on-missing axis for this call (`None` defers
|
|
229
|
+
to the active policy).
|
|
230
|
+
on_inexact: Override the on-inexact axis for this call (`None` defers
|
|
231
|
+
to the active policy).
|
|
232
|
+
qualname: A qualifier prepended to warning messages as `[qualname]`,
|
|
233
|
+
identifying the calling site.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
A new `DataArray` converted to `declared`, with `attrs["units"]` set
|
|
237
|
+
to `declared`. When the input is already in `declared` the data is not
|
|
238
|
+
copied — the result is a shallow copy sharing the caller's buffer, so
|
|
239
|
+
declaring a unit an array already has costs nothing.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
ValueError: When `on_missing="error"` and no parseable unit is found,
|
|
243
|
+
or when `on_inexact="error"` and a value-changing conversion would occur.
|
|
244
|
+
pint.DimensionalityError: When the actual unit is dimensionally
|
|
245
|
+
incompatible with `declared` (always, regardless of policy).
|
|
246
|
+
|
|
247
|
+
Examples:
|
|
248
|
+
>>> import numpy as np, xarray as xr
|
|
249
|
+
>>> from xarray_annotated.units import check_units
|
|
250
|
+
>>> da = xr.DataArray([1013.0, 1000.0], attrs={"units": "hPa"})
|
|
251
|
+
>>> out = check_units(da, "Pa", "pressure")
|
|
252
|
+
>>> out.attrs["units"]
|
|
253
|
+
'Pa'
|
|
254
|
+
>>> out.values # 10 * 100 = 1000
|
|
255
|
+
array([101300., 100000.])
|
|
256
|
+
"""
|
|
257
|
+
pol = get_policy()
|
|
258
|
+
if not pol.enabled:
|
|
259
|
+
return da
|
|
260
|
+
on_missing = (
|
|
261
|
+
pol.on_missing if on_missing is None else _validate_on_missing(on_missing)
|
|
262
|
+
)
|
|
263
|
+
on_inexact = (
|
|
264
|
+
pol.on_inexact if on_inexact is None else _validate_on_inexact(on_inexact)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
prefix = f"[{qualname}] " if qualname else ""
|
|
268
|
+
quantified = da.pint.units # None unless the data is a pint Quantity
|
|
269
|
+
if quantified is not None:
|
|
270
|
+
# A pint label lives in the data and cannot go stale, so it wins outright
|
|
271
|
+
# over attrs. It has to: a quantified array normally has *empty* attrs
|
|
272
|
+
# (quantify moves the label into the data), and when a leftover attrs
|
|
273
|
+
# label is also present, quantify() refuses to reconcile the two
|
|
274
|
+
# ("Cannot attach units"), so reading attrs here would crash the pipeline
|
|
275
|
+
# below on exactly the arrays this branch exists to serve.
|
|
276
|
+
have = _quantified_label(quantified)
|
|
277
|
+
else:
|
|
278
|
+
have = da.attrs.get("units")
|
|
279
|
+
if have is None:
|
|
280
|
+
if on_missing == "error":
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"{prefix}input {name!r} has no 'units' attribute "
|
|
283
|
+
f"(declared {declared!r})"
|
|
284
|
+
)
|
|
285
|
+
if on_missing == "warn":
|
|
286
|
+
# stacklevel=2 targets a direct check_units call; via declare_units it
|
|
287
|
+
# lands on the wrapper line, still identified by the [qualname] prefix.
|
|
288
|
+
warnings.warn(
|
|
289
|
+
f"{prefix}input {name!r} unvalidated: no 'units' attribute "
|
|
290
|
+
f"(declared {declared!r})",
|
|
291
|
+
UnitsWarning,
|
|
292
|
+
stacklevel=2,
|
|
293
|
+
)
|
|
294
|
+
return da
|
|
295
|
+
if isinstance(have, str) and len(have) > _MAX_UNIT_LEN:
|
|
296
|
+
# An over-long units attribute can hang pint's parser (DoS); refuse it
|
|
297
|
+
# *before* parsing and route it through on_missing like any other input we
|
|
298
|
+
# cannot validate. Never echo `have` in the message -- it may be huge.
|
|
299
|
+
if on_missing == "error":
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"{prefix}input {name!r} has an over-long 'units' attribute "
|
|
302
|
+
f"({len(have)} chars > {_MAX_UNIT_LEN}; declared {declared!r}); "
|
|
303
|
+
f"refusing to parse"
|
|
304
|
+
)
|
|
305
|
+
if on_missing == "warn":
|
|
306
|
+
warnings.warn(
|
|
307
|
+
f"{prefix}input {name!r} unvalidated: over-long 'units' attribute "
|
|
308
|
+
f"({len(have)} chars > {_MAX_UNIT_LEN}; declared {declared!r})",
|
|
309
|
+
UnitsWarning,
|
|
310
|
+
stacklevel=2,
|
|
311
|
+
)
|
|
312
|
+
return da
|
|
313
|
+
try:
|
|
314
|
+
get_registry().Unit(have)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
# A units attribute that exists but the registry cannot parse can no more be
|
|
317
|
+
# validated than a missing one; route it through the same on_missing policy
|
|
318
|
+
# rather than letting an opaque parse error escape (and break a warn run).
|
|
319
|
+
if on_missing == "error":
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"{prefix}input {name!r} has unparseable 'units' attribute {have!r} "
|
|
322
|
+
f"(declared {declared!r}): {type(exc).__name__}: {exc}"
|
|
323
|
+
f"{_cf_hint(have)}"
|
|
324
|
+
) from exc
|
|
325
|
+
if on_missing == "warn":
|
|
326
|
+
warnings.warn(
|
|
327
|
+
f"{prefix}input {name!r} unvalidated: unparseable 'units' attribute "
|
|
328
|
+
f"{have!r} (declared {declared!r}){_cf_hint(have)}",
|
|
329
|
+
UnitsWarning,
|
|
330
|
+
stacklevel=2,
|
|
331
|
+
)
|
|
332
|
+
return da
|
|
333
|
+
if quantified is None and units_equal(have, declared):
|
|
334
|
+
# Same unit, differently spelled ("pascal" for "Pa", "1" for
|
|
335
|
+
# "dimensionless"): nothing to convert, so the quantify/convert/dequantify
|
|
336
|
+
# round-trip below would copy the entire buffer purely to rewrite a string
|
|
337
|
+
# -- and would rewrite the *coordinates'* unit spellings on the way past.
|
|
338
|
+
# A shallow copy shares the data and takes its own attrs dict, so the
|
|
339
|
+
# caller's array is left exactly as it was.
|
|
340
|
+
#
|
|
341
|
+
# Quantified inputs still go the long way round: they are contracted to
|
|
342
|
+
# come back dequantified, and only dequantify can do that.
|
|
343
|
+
relabelled = da.copy(deep=False)
|
|
344
|
+
relabelled.attrs["units"] = declared
|
|
345
|
+
return relabelled
|
|
346
|
+
if units_compatible(have, declared) and not units_equal(have, declared):
|
|
347
|
+
# Dimensionally compatible but value-changing (e.g. hPa -> Pa).
|
|
348
|
+
if on_inexact == "error":
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"{prefix}input {name!r}: unit {have!r} differs from declared "
|
|
351
|
+
f"{declared!r} and on_inexact='error' forbids value-changing conversion"
|
|
352
|
+
)
|
|
353
|
+
if on_inexact == "warn":
|
|
354
|
+
warnings.warn(
|
|
355
|
+
f"{prefix}input {name!r}: converting {have!r} -> {declared!r} "
|
|
356
|
+
f"(value-changing)",
|
|
357
|
+
UnitsWarning,
|
|
358
|
+
stacklevel=2,
|
|
359
|
+
)
|
|
360
|
+
try:
|
|
361
|
+
# quantify() is not a no-op on already-quantified data -- it raises when
|
|
362
|
+
# a leftover attrs label disagrees with the Quantity -- so skip it there.
|
|
363
|
+
source = da if quantified is not None else da.pint.quantify()
|
|
364
|
+
converted = source.pint.to(declared).pint.dequantify()
|
|
365
|
+
except PintExceptionGroup as group:
|
|
366
|
+
# pint-xarray wraps conversion failures in an ExceptionGroup; surface the
|
|
367
|
+
# underlying DimensionalityError directly for a clean, catchable error.
|
|
368
|
+
dim_errors = [
|
|
369
|
+
exc for exc in group.exceptions if isinstance(exc, pint.DimensionalityError)
|
|
370
|
+
]
|
|
371
|
+
if dim_errors:
|
|
372
|
+
err = dim_errors[0]
|
|
373
|
+
err.add_note(f"while validating input {name!r}")
|
|
374
|
+
raise err from None
|
|
375
|
+
raise
|
|
376
|
+
# dequantify writes pint's canonical unit name (e.g. 'pascal'); restore the
|
|
377
|
+
# declared unit string so downstream re-parsing uses our spelling.
|
|
378
|
+
converted.attrs["units"] = declared
|
|
379
|
+
return converted
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _apply_output_quantified(
|
|
383
|
+
da: xr.DataArray,
|
|
384
|
+
have: pint.Unit,
|
|
385
|
+
declared: str,
|
|
386
|
+
name: str,
|
|
387
|
+
on_output: OnOutput,
|
|
388
|
+
prefix: str,
|
|
389
|
+
) -> xr.DataArray:
|
|
390
|
+
"""Apply a declared unit to a *pint-quantified* return value.
|
|
391
|
+
|
|
392
|
+
See `apply_output_units` for why this path converts where the `attrs` path
|
|
393
|
+
stamps. The array stays quantified throughout: `dequantify` copies the whole
|
|
394
|
+
buffer even when it has no conversion to do, and a quantified array already
|
|
395
|
+
describes itself, so there is nothing to gain by paying for that.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
da: The returned `DataArray`, known to hold a pint `Quantity`.
|
|
399
|
+
have: Its own unit, from `da.pint.units`.
|
|
400
|
+
declared: The declared unit string.
|
|
401
|
+
name: A label for the value, used in error messages.
|
|
402
|
+
on_output: The already-resolved on-output axis.
|
|
403
|
+
prefix: The `[qualname] ` message prefix, or `""`.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
`da` unchanged when it is already in `declared`; otherwise a converted
|
|
407
|
+
array (still quantified).
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
pint.DimensionalityError: When the unit cannot be converted to
|
|
411
|
+
`declared` — under `"strict"`, or under `"stamp"` from the
|
|
412
|
+
conversion itself.
|
|
413
|
+
ValueError: Under `"strict"`, when the unit is compatible with but not
|
|
414
|
+
equal to `declared`.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
label = _quantified_label(have)
|
|
418
|
+
equal = units_equal(label, declared)
|
|
419
|
+
except Exception:
|
|
420
|
+
# A unit undefined in the active registry (the array was quantified
|
|
421
|
+
# under a different one). Unverifiable and unconvertible from here --
|
|
422
|
+
# return it untouched rather than stamping attrs onto a Quantity and
|
|
423
|
+
# creating the two-labels-at-once inconsistency this path exists to
|
|
424
|
+
# avoid.
|
|
425
|
+
return da
|
|
426
|
+
if equal:
|
|
427
|
+
return da
|
|
428
|
+
if on_output == "strict":
|
|
429
|
+
if units_compatible(label, declared):
|
|
430
|
+
raise ValueError(
|
|
431
|
+
f"{prefix}output {name!r}: unit {label!r} differs from declared "
|
|
432
|
+
f"{declared!r} and on_output='strict' requires them to match"
|
|
433
|
+
)
|
|
434
|
+
# Fall through: the conversion below raises DimensionalityError for us.
|
|
435
|
+
try:
|
|
436
|
+
return da.pint.to(declared)
|
|
437
|
+
except PintExceptionGroup as group:
|
|
438
|
+
dim_errors = [
|
|
439
|
+
exc for exc in group.exceptions if isinstance(exc, pint.DimensionalityError)
|
|
440
|
+
]
|
|
441
|
+
if dim_errors:
|
|
442
|
+
err = dim_errors[0]
|
|
443
|
+
err.add_note(
|
|
444
|
+
f"while applying declared units to output {name!r}: the returned "
|
|
445
|
+
f"array is quantified as {label!r} but declares {declared!r}"
|
|
446
|
+
)
|
|
447
|
+
raise err from None
|
|
448
|
+
raise
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def apply_output_units(
|
|
452
|
+
da: xr.DataArray,
|
|
453
|
+
declared: str,
|
|
454
|
+
name: str,
|
|
455
|
+
on_output: OnOutput | None = None,
|
|
456
|
+
qualname: str | None = None,
|
|
457
|
+
) -> xr.DataArray:
|
|
458
|
+
"""Apply a declared unit to a *returned* `DataArray`, per the on-output axis.
|
|
459
|
+
|
|
460
|
+
The output counterpart to `check_units`, and deliberately **not** symmetric
|
|
461
|
+
with it: a declared output carrying an `attrs` label is *stamped*, never
|
|
462
|
+
converted.
|
|
463
|
+
|
|
464
|
+
The reason is that xarray's `attrs` are inert under arithmetic. A body that
|
|
465
|
+
converts by scalar multiplication — `return p * 100.0`, or `flux * F` for a
|
|
466
|
+
molar-mass factor — produces an array still labelled with its *input's* unit,
|
|
467
|
+
because multiplying by a float cannot update a string. So for the dominant
|
|
468
|
+
idiom the returned label describes neither the values nor the declaration,
|
|
469
|
+
and converting on the strength of it would rescale correct values a second
|
|
470
|
+
time.
|
|
471
|
+
|
|
472
|
+
Two modes follow from that:
|
|
473
|
+
|
|
474
|
+
- `"stamp"` (default) — overwrite `attrs["units"]` with `declared`, no checks.
|
|
475
|
+
The only sound default, because a stale label is the norm rather than a
|
|
476
|
+
symptom.
|
|
477
|
+
- `"strict"` — a label that is present, parseable, and not equal to
|
|
478
|
+
`declared` raises.
|
|
479
|
+
|
|
480
|
+
**`"strict"` is only meaningful for bodies that maintain their own units** —
|
|
481
|
+
pass-through and subsetting functions, or computations on pint-quantified
|
|
482
|
+
arrays. A body doing manual scalar arithmetic will fail it *whether or not
|
|
483
|
+
it is correct*, so this is an opt-in for unit-aware code, not a general
|
|
484
|
+
verification mode. (If a body must do manual arithmetic and you still want
|
|
485
|
+
`"strict"` elsewhere, have it drop the stale label — `attrs` cleared, or
|
|
486
|
+
`keep_attrs=False` — since an absent label is always stamped.)
|
|
487
|
+
|
|
488
|
+
**Pint-quantified returns are the exception, and are converted rather than
|
|
489
|
+
stamped.** A `Quantity`'s unit lives in the data, so — unlike `attrs` — it
|
|
490
|
+
cannot be left behind by arithmetic; it is always a true description of the
|
|
491
|
+
values. Converting on the strength of it is therefore sound, and stamping
|
|
492
|
+
on the strength of it would be actively wrong: writing `attrs["units"]`
|
|
493
|
+
onto a `Quantity` that disagrees produces an array labelled two ways at
|
|
494
|
+
once. So for a quantified return:
|
|
495
|
+
|
|
496
|
+
- `"stamp"` — converts to `declared` if it is not already there, and raises
|
|
497
|
+
`pint.DimensionalityError` if it cannot. This is the one case where
|
|
498
|
+
`"stamp"` can raise, because here a mismatch is a real error rather than
|
|
499
|
+
the expected consequence of an inert label.
|
|
500
|
+
- `"strict"` — a unit other than `declared` raises, as for `attrs`.
|
|
501
|
+
|
|
502
|
+
Either way the array is **returned still quantified**, with `attrs`
|
|
503
|
+
untouched: it already describes itself, and dequantifying it purely to
|
|
504
|
+
write a string would copy the whole buffer.
|
|
505
|
+
|
|
506
|
+
Note what no mode can do: distinguish `flux * F` from `flux` when both are
|
|
507
|
+
labelled the same. Output *values* are never inspected, so a forgotten
|
|
508
|
+
conversion factor is not detectable here — only at a consumer that declares
|
|
509
|
+
the quantity and receives an array that was never stamped.
|
|
510
|
+
|
|
511
|
+
An absent, over-long, or unparseable label is always stamped: absence is not
|
|
512
|
+
evidence of a mismatch.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
da: The returned `DataArray`.
|
|
516
|
+
declared: The declared unit string (e.g. `"Pa"`).
|
|
517
|
+
name: A label for the value, used in error messages.
|
|
518
|
+
on_output: Override the on-output axis for this call (`None` defers to
|
|
519
|
+
the active policy).
|
|
520
|
+
qualname: A qualifier prepended to messages as `[qualname]`.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
For an `attrs`-labelled or unlabelled array, `da` itself with
|
|
524
|
+
`attrs["units"]` set to `declared` (mutated in place). For a quantified
|
|
525
|
+
array, an array in `declared` with `attrs` untouched — the same object
|
|
526
|
+
when it was already in `declared`, otherwise a converted new one. Always
|
|
527
|
+
use the return value rather than relying on mutation.
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
pint.DimensionalityError: Under `"strict"`, when the returned array's own
|
|
531
|
+
unit is dimensionally incompatible with `declared`; also under
|
|
532
|
+
`"stamp"` for a quantified array that cannot be converted.
|
|
533
|
+
ValueError: Under `"strict"`, when the returned array's unit is
|
|
534
|
+
compatible with but not equal to `declared`.
|
|
535
|
+
|
|
536
|
+
Examples:
|
|
537
|
+
>>> import xarray as xr
|
|
538
|
+
>>> from xarray_annotated.units._check import apply_output_units
|
|
539
|
+
>>> da = xr.DataArray([1.0])
|
|
540
|
+
>>> apply_output_units(da, "Pa", "return").attrs["units"]
|
|
541
|
+
'Pa'
|
|
542
|
+
"""
|
|
543
|
+
pol = get_policy()
|
|
544
|
+
if not pol.enabled:
|
|
545
|
+
return da
|
|
546
|
+
on_output = pol.on_output if on_output is None else _validate_on_output(on_output)
|
|
547
|
+
prefix = f"[{qualname}] " if qualname else ""
|
|
548
|
+
|
|
549
|
+
quantified = da.pint.units
|
|
550
|
+
if quantified is not None:
|
|
551
|
+
return _apply_output_quantified(
|
|
552
|
+
da, quantified, declared, name, on_output, prefix
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
have = da.attrs.get("units")
|
|
556
|
+
if on_output == "stamp" or have is None:
|
|
557
|
+
da.attrs["units"] = declared
|
|
558
|
+
return da
|
|
559
|
+
if not isinstance(have, str) or len(have) > _MAX_UNIT_LEN:
|
|
560
|
+
# Unverifiable for the same reasons as on the input path; stamp regardless.
|
|
561
|
+
da.attrs["units"] = declared
|
|
562
|
+
return da
|
|
563
|
+
try:
|
|
564
|
+
get_registry().Unit(have)
|
|
565
|
+
except Exception:
|
|
566
|
+
da.attrs["units"] = declared
|
|
567
|
+
return da
|
|
568
|
+
|
|
569
|
+
if not units_equal(have, declared):
|
|
570
|
+
if not units_compatible(have, declared):
|
|
571
|
+
try:
|
|
572
|
+
get_registry().Quantity(1.0, have).to(declared)
|
|
573
|
+
except pint.DimensionalityError as exc:
|
|
574
|
+
raise pint.DimensionalityError(
|
|
575
|
+
exc.units1,
|
|
576
|
+
exc.units2,
|
|
577
|
+
exc.dim1,
|
|
578
|
+
exc.dim2,
|
|
579
|
+
extra_msg=(
|
|
580
|
+
f" ({prefix}output {name!r} is labelled {have!r} but declares "
|
|
581
|
+
f"{declared!r}, and on_output='strict' requires them to match; "
|
|
582
|
+
f"if the body converts by scalar arithmetic, clear its 'units' "
|
|
583
|
+
f"attribute or use on_output='stamp')"
|
|
584
|
+
),
|
|
585
|
+
) from None
|
|
586
|
+
else:
|
|
587
|
+
raise ValueError(
|
|
588
|
+
f"{prefix}output {name!r}: unit {have!r} differs from declared "
|
|
589
|
+
f"{declared!r} and on_output='strict' requires them to match"
|
|
590
|
+
)
|
|
591
|
+
da.attrs["units"] = declared
|
|
592
|
+
return da
|