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.
@@ -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