nomogram 0.1.0__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.
nomogram/plot.py ADDED
@@ -0,0 +1,176 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Plots of comparisons, with matplotlib (extra ``viz``).
5
+
6
+ matplotlib is imported inside the functions only, so that the package
7
+ works without it; a missing extra gives an explicit message
8
+ (``pip install nomogram[viz]``).
9
+
10
+ Values outside a formulation's validity domain are NaN (see
11
+ :meth:`nomogram.model.Formulation.evaluate`), so they show as gaps in the
12
+ curves: the domains are visible on the plot.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ from nomogram.comparison import Comparison
22
+ from nomogram.expression import to_latex
23
+ from nomogram.model import Notation
24
+ from nomogram.semantics import semantic_catalog
25
+
26
+ __all__ = ["plot_deviations", "plot_values"]
27
+
28
+
29
+ def _pyplot() -> Any:
30
+ try:
31
+ import matplotlib.pyplot as plt
32
+ except ImportError as exc:
33
+ msg = "nomogram.plot needs matplotlib: install the extra, nomogram[viz]"
34
+ raise ImportError(msg) from exc
35
+ return plt
36
+
37
+
38
+ def _axes(ax: Any) -> Any:
39
+ if ax is not None:
40
+ return ax
41
+ _, created = _pyplot().subplots()
42
+ return created
43
+
44
+
45
+ def _abscissa(comparison: Comparison, kind: str | None) -> tuple[str, Any]:
46
+ """The input plotted along x: its label and its values."""
47
+ if kind is None:
48
+ varying = [k for k, q in comparison.inputs.items() if np.ndim(q.magnitude) == 1]
49
+ if len(varying) != 1:
50
+ msg = (
51
+ "choose the input to plot along x with input_kind; "
52
+ f"one-dimensional inputs: {', '.join(sorted(varying)) or 'none'}"
53
+ )
54
+ raise ValueError(msg)
55
+ kind = varying[0]
56
+ if kind not in comparison.inputs:
57
+ msg = f"no input of kind {kind!r} in the comparison"
58
+ raise ValueError(msg)
59
+ quantity = comparison.inputs[kind]
60
+ kinds = semantic_catalog().quantity_kinds
61
+ name = kinds[kind].label if kind in kinds else kind
62
+ return f"{name} [{quantity.units:~P}]", np.asarray(quantity.magnitude, dtype=float)
63
+
64
+
65
+ #: Line styles taken in turn once the colour cycle (10 colours) is used up.
66
+ _LINESTYLES = ("-", "-.", ":")
67
+
68
+
69
+ def _style(index: int) -> dict[str, str]:
70
+ """Distinct styles beyond ten curves: the colour cycle repeats."""
71
+ return {"linestyle": _LINESTYLES[(index // 10) % len(_LINESTYLES)]}
72
+
73
+
74
+ def _label(comparison: Comparison, key: str) -> str:
75
+ formulation = comparison.formulations.get(key)
76
+ label = getattr(formulation, "label", None)
77
+ return str(label) if label else key
78
+
79
+
80
+ def plot_deviations(
81
+ comparison: Comparison, *, input_kind: str | None = None, ax: Any = None
82
+ ) -> Any:
83
+ """Plot the relative deviation of each formulation from the reference, in %.
84
+
85
+ Parameters
86
+ ----------
87
+ comparison : Comparison
88
+ The result of :func:`nomogram.comparison.compare`.
89
+ input_kind : str, optional
90
+ Quantity kind of the input plotted along x; needed only when
91
+ several inputs are one-dimensional arrays.
92
+ ax : matplotlib.axes.Axes, optional
93
+ Axes to draw on; new ones are created otherwise.
94
+
95
+ Returns
96
+ -------
97
+ matplotlib.axes.Axes
98
+ The axes, for further customisation.
99
+
100
+ Raises
101
+ ------
102
+ ImportError
103
+ If matplotlib is not installed (extra ``viz``).
104
+ ValueError
105
+ If the input to plot along x cannot be determined.
106
+ """
107
+ x_label, x = _abscissa(comparison, input_kind)
108
+ ax = _axes(ax)
109
+ others = [k for k in comparison.relative_deviations if k != comparison.reference]
110
+ for index, key in enumerate(others):
111
+ deviation = comparison.relative_deviations[key]
112
+ values = np.broadcast_to(np.asarray(deviation, dtype=float), x.shape)
113
+ ax.plot(x, 100 * values, label=_label(comparison, key), **_style(index))
114
+ ax.axhline(0.0, color="0.4", linewidth=0.8, linestyle="--")
115
+ ax.set_xlabel(x_label)
116
+ ax.set_ylabel(f"Deviation from {_label(comparison, comparison.reference)} [%]")
117
+ ax.legend(fontsize="small")
118
+ return ax
119
+
120
+
121
+ def plot_values(
122
+ comparison: Comparison,
123
+ *,
124
+ input_kind: str | None = None,
125
+ notation: Notation | None = None,
126
+ ax: Any = None,
127
+ ) -> Any:
128
+ """Plot the values of each formulation, the reference included.
129
+
130
+ Parameters
131
+ ----------
132
+ comparison : Comparison
133
+ The result of :func:`nomogram.comparison.compare`.
134
+ input_kind : str, optional
135
+ Quantity kind of the input plotted along x.
136
+ notation : Notation, optional
137
+ Notation of the y-axis symbol (for example the WMO one); the
138
+ identifier's default rendering otherwise.
139
+ ax : matplotlib.axes.Axes, optional
140
+ Axes to draw on; new ones are created otherwise.
141
+
142
+ Returns
143
+ -------
144
+ matplotlib.axes.Axes
145
+ The axes, for further customisation.
146
+
147
+ Raises
148
+ ------
149
+ ImportError
150
+ If matplotlib is not installed (extra ``viz``).
151
+ ValueError
152
+ If the input to plot along x cannot be determined.
153
+ """
154
+ import sympy
155
+
156
+ x_label, x = _abscissa(comparison, input_kind)
157
+ ax = _axes(ax)
158
+ for index, (key, quantity) in enumerate(comparison.values.items()):
159
+ values = np.broadcast_to(np.asarray(quantity.magnitude, dtype=float), x.shape)
160
+ style = (
161
+ {"color": "black", "linestyle": "--"}
162
+ if key == comparison.reference
163
+ else _style(index)
164
+ )
165
+ ax.plot(x, values, label=_label(comparison, key), **style)
166
+ reference = comparison.formulations.get(comparison.reference)
167
+ unit = comparison.values[comparison.reference].units
168
+ if reference is not None:
169
+ symbols = dict(notation.symbols) if notation else {}
170
+ symbol = to_latex(sympy.Symbol(reference.output.identifier), symbols)
171
+ ax.set_ylabel(f"${symbol}$ [{unit:~P}]")
172
+ else:
173
+ ax.set_ylabel(f"[{unit:~P}]")
174
+ ax.set_xlabel(x_label)
175
+ ax.legend(fontsize="small")
176
+ return ax
nomogram/py.typed ADDED
File without changes
nomogram/semantics.py ADDED
@@ -0,0 +1,333 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Quantity kinds, units and CF standard names, from bundled QUDT and CF subsets.
5
+
6
+ nomogram does not build its own ontology. It bundles a frozen subset of
7
+ `QUDT <https://qudt.org/>`_ (quantity kinds and unit definitions, CC BY
8
+ 4.0) and of the `CF standard name table <https://cfconventions.org/>`_
9
+ (CC0 1.0), extracted at development time by
10
+ ``scripts/extract_semantics.py``. Nothing is fetched at run time.
11
+
12
+ On top of QUDT, nomogram states the scale type of each kind and whether
13
+ it is a difference (QUDT's *delta quantity*, OM's ``Measure``) or a point;
14
+ the scale type decides which units apply (offset units only for
15
+ interval-scale kinds). Links to external vocabularies (Wikidata...) are
16
+ :class:`ExternalMatch` objects carrying a SKOS relation and the date they
17
+ were last verified. ``docs/rosetta.md`` maps this vocabulary.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime
23
+ import json
24
+ import re
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass
27
+ from enum import Enum
28
+ from functools import lru_cache
29
+ from importlib.resources import files
30
+ from types import MappingProxyType
31
+ from typing import Any
32
+
33
+ from nomogram.units import ScaleType
34
+
35
+ __all__ = [
36
+ "CFStandardName",
37
+ "ExternalMatch",
38
+ "MatchRelation",
39
+ "QuantityKind",
40
+ "SemanticCatalog",
41
+ "UnitDefinition",
42
+ "semantic_catalog",
43
+ ]
44
+
45
+ _DIMENSION_VECTOR = re.compile(
46
+ r"^A(-?\d+)E(-?\d+)L(-?\d+)I(-?\d+)M(-?\d+)H(-?\d+)T(-?\d+)D([01])$"
47
+ )
48
+
49
+ #: Base dimensions of a QUDT dimension vector, in order.
50
+ _BASE_DIMENSIONS = (
51
+ "amount_of_substance",
52
+ "electric_current",
53
+ "length",
54
+ "luminous_intensity",
55
+ "mass",
56
+ "temperature",
57
+ "time",
58
+ )
59
+
60
+
61
+ class MatchRelation(Enum):
62
+ """SKOS mapping relation between a nomogram object and an external one."""
63
+
64
+ EXACT = "exact"
65
+ CLOSE = "close"
66
+ BROAD = "broad"
67
+ NARROW = "narrow"
68
+
69
+ @property
70
+ def skos(self) -> str:
71
+ """IRI of the corresponding SKOS property."""
72
+ return f"http://www.w3.org/2004/02/skos/core#{self.value}Match"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ExternalMatch:
77
+ """Link from a nomogram object to an object of an external vocabulary.
78
+
79
+ Attributes
80
+ ----------
81
+ target : str
82
+ IRI of the external object, for example a Wikidata entity.
83
+ relation : MatchRelation
84
+ How close the two objects are. Links imported from QUDT's
85
+ ``wikidataMatch``, a plain annotation, are close matches.
86
+ verified : datetime.date
87
+ Date the link was last verified against its source.
88
+ """
89
+
90
+ target: str
91
+ relation: MatchRelation
92
+ verified: datetime.date
93
+
94
+
95
+ def _dimensions(vector: str) -> Mapping[str, int]:
96
+ match = _DIMENSION_VECTOR.match(vector)
97
+ if match is None:
98
+ msg = f"invalid QUDT dimension vector: {vector!r}"
99
+ raise ValueError(msg)
100
+ exponents = (int(e) for e in match.groups()[:7])
101
+ return MappingProxyType(
102
+ {d: e for d, e in zip(_BASE_DIMENSIONS, exponents, strict=True) if e}
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class QuantityKind:
108
+ """A quantity kind, from QUDT or defined by nomogram, with its scale typing.
109
+
110
+ Kinds defined by nomogram (absent from QUDT) specialize a QUDT kind
111
+ and have no IRI yet: their local name is stable and will become the
112
+ end of a persistent IRI once nomogram has one.
113
+
114
+ Attributes
115
+ ----------
116
+ name : str
117
+ Local name, for example ``"CelsiusTemperature"``.
118
+ origin : str
119
+ ``"qudt"`` or ``"nomogram"``.
120
+ iri : str or None
121
+ QUDT IRI; ``None`` for a kind defined by nomogram.
122
+ label : str
123
+ English label.
124
+ dimension_vector : str
125
+ QUDT dimension vector, for example ``"A0E0L0I0M0H1T0D0"``.
126
+ specialization_of : str or None
127
+ QUDT local name of the kind this one specializes.
128
+ scale_type : ScaleType
129
+ Scale of the kind's values (nomogram's addition).
130
+ delta : bool
131
+ Whether values are differences rather than points (nomogram's
132
+ addition; QUDT's ``isDeltaQuantity`` is set on no kind).
133
+ cf_standard_names : tuple of str
134
+ CF standard names for this kind.
135
+ applicable_units : tuple of str
136
+ QUDT local names of the units that apply.
137
+ added_units : tuple of str
138
+ The applicable units QUDT does not declare, added by nomogram.
139
+ matches : tuple of ExternalMatch
140
+ Links to external vocabularies.
141
+ """
142
+
143
+ name: str
144
+ origin: str
145
+ iri: str | None
146
+ label: str
147
+ dimension_vector: str
148
+ specialization_of: str | None
149
+ scale_type: ScaleType
150
+ delta: bool
151
+ cf_standard_names: tuple[str, ...]
152
+ applicable_units: tuple[str, ...]
153
+ added_units: tuple[str, ...]
154
+ matches: tuple[ExternalMatch, ...]
155
+
156
+ @property
157
+ def dimensions(self) -> Mapping[str, int]:
158
+ """Non-zero exponents of the base dimensions, by dimension name."""
159
+ return _dimensions(self.dimension_vector)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class UnitDefinition:
164
+ """A QUDT unit definition, with its pint expression.
165
+
166
+ A value v in this unit is ``(v + offset) * multiplier`` in the coherent
167
+ SI unit of the same dimension (QUDT's conversion rule).
168
+
169
+ Attributes
170
+ ----------
171
+ name : str
172
+ QUDT local name, for example ``"DEG_C"``.
173
+ iri : str
174
+ QUDT IRI.
175
+ label : str
176
+ QUDT English label.
177
+ symbol : str or None
178
+ QUDT symbol (the glyph).
179
+ dimension_vector : str
180
+ QUDT dimension vector.
181
+ multiplier : float
182
+ QUDT ``conversionMultiplier``.
183
+ offset : float
184
+ QUDT ``conversionOffset``; non-zero for offset units such as
185
+ ``DEG_C``.
186
+ ucum : str or None
187
+ UCUM code.
188
+ udunits : str or None
189
+ UDUNITS code.
190
+ pint : str
191
+ Expression of the unit in pint, for example ``"degC"``.
192
+ matches : tuple of ExternalMatch
193
+ Links to external vocabularies.
194
+ """
195
+
196
+ name: str
197
+ iri: str
198
+ label: str
199
+ symbol: str | None
200
+ dimension_vector: str
201
+ multiplier: float
202
+ offset: float
203
+ ucum: str | None
204
+ udunits: str | None
205
+ pint: str
206
+ matches: tuple[ExternalMatch, ...]
207
+
208
+ @property
209
+ def dimensions(self) -> Mapping[str, int]:
210
+ """Non-zero exponents of the base dimensions, by dimension name."""
211
+ return _dimensions(self.dimension_vector)
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class CFStandardName:
216
+ """A CF standard name and its canonical unit.
217
+
218
+ Attributes
219
+ ----------
220
+ name : str
221
+ The standard name, for example ``"relative_humidity"``.
222
+ canonical_units : str
223
+ CF canonical unit, in UDUNITS syntax (``"kg m-3"``).
224
+ pint : str
225
+ The same unit as a pint expression (``"kg * m ** -3"``).
226
+ """
227
+
228
+ name: str
229
+ canonical_units: str
230
+ pint: str
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class SemanticCatalog:
235
+ """The bundled subsets, read-only.
236
+
237
+ Attributes
238
+ ----------
239
+ quantity_kinds : mapping of str to QuantityKind
240
+ By QUDT local name.
241
+ units : mapping of str to UnitDefinition
242
+ By QUDT local name.
243
+ cf_standard_names : mapping of str to CFStandardName
244
+ By standard name.
245
+ qudt_version : str
246
+ QUDT release the subset was extracted from.
247
+ cf_version : str
248
+ CF standard name table version.
249
+ extracted : datetime.date
250
+ Extraction date.
251
+ """
252
+
253
+ quantity_kinds: Mapping[str, QuantityKind]
254
+ units: Mapping[str, UnitDefinition]
255
+ cf_standard_names: Mapping[str, CFStandardName]
256
+ qudt_version: str
257
+ cf_version: str
258
+ extracted: datetime.date
259
+
260
+
261
+ def _matches(items: list[dict[str, str]]) -> tuple[ExternalMatch, ...]:
262
+ return tuple(
263
+ ExternalMatch(
264
+ target=item["target"],
265
+ relation=MatchRelation(item["relation"]),
266
+ verified=datetime.date.fromisoformat(item["verified"]),
267
+ )
268
+ for item in items
269
+ )
270
+
271
+
272
+ def _kind(name: str, data: dict[str, Any]) -> QuantityKind:
273
+ return QuantityKind(
274
+ name=name,
275
+ origin="qudt",
276
+ iri=data["iri"],
277
+ label=data["label"],
278
+ dimension_vector=data["dimension_vector"],
279
+ specialization_of=data["specialization_of"],
280
+ scale_type=ScaleType(data["scale_type"]),
281
+ delta=data["delta"],
282
+ cf_standard_names=tuple(data["cf_standard_names"]),
283
+ applicable_units=tuple(data["applicable_units"]),
284
+ added_units=tuple(data["added_units"]),
285
+ matches=_matches(data["matches"]),
286
+ )
287
+
288
+
289
+ def _unit(name: str, data: dict[str, Any]) -> UnitDefinition:
290
+ return UnitDefinition(
291
+ name=name,
292
+ iri=data["iri"],
293
+ label=data["label"],
294
+ symbol=data["symbol"],
295
+ dimension_vector=data["dimension_vector"],
296
+ multiplier=data["multiplier"],
297
+ offset=data["offset"],
298
+ ucum=data["ucum"],
299
+ udunits=data["udunits"],
300
+ pint=data["pint"],
301
+ matches=_matches(data["matches"]),
302
+ )
303
+
304
+
305
+ @lru_cache(maxsize=1)
306
+ def semantic_catalog() -> SemanticCatalog:
307
+ """Return the bundled QUDT and CF subsets.
308
+
309
+ The file is read once; the catalog is immutable and shared.
310
+
311
+ Returns
312
+ -------
313
+ SemanticCatalog
314
+ Quantity kinds, unit definitions and CF standard names.
315
+ """
316
+ resource = files("nomogram") / "data" / "semantics.json"
317
+ data = json.loads(resource.read_text(encoding="utf-8"))
318
+ sources = data["sources"]
319
+ return SemanticCatalog(
320
+ quantity_kinds=MappingProxyType(
321
+ {n: _kind(n, d) for n, d in data["quantity_kinds"].items()}
322
+ ),
323
+ units=MappingProxyType({n: _unit(n, d) for n, d in data["units"].items()}),
324
+ cf_standard_names=MappingProxyType(
325
+ {
326
+ n: CFStandardName(n, d["canonical_units"], d["pint"])
327
+ for n, d in data["cf_standard_names"].items()
328
+ }
329
+ ),
330
+ qudt_version=sources["qudt"]["version"],
331
+ cf_version=sources["cf_standard_names"]["version"],
332
+ extracted=datetime.date.fromisoformat(sources["extracted"]),
333
+ )
nomogram/units.py ADDED
@@ -0,0 +1,118 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Units: a private pint registry and the scale rules nomogram relies on.
5
+
6
+ nomogram uses `pint <https://pint.readthedocs.io/>`_ as its unit engine,
7
+ so that quantity values coming from MetPy or xarray (pint-xarray) are
8
+ accepted as they are. A pint ``Quantity`` (magnitude and unit) is what
9
+ QUDT calls a ``QuantityValue`` and the VIM a *quantity value* (1.19).
10
+ nomogram's own registry is created once, here, and never modified
11
+ afterwards: nomogram never touches pint's global application registry.
12
+
13
+ Scale semantics follow the SI Brochure (9th edition, section 2.3.1): the
14
+ degree Celsius is equal in magnitude to the kelvin; what is offset is the
15
+ quantity *Celsius temperature*, t = T - T0 with T0 = 273.15 K. pint, like
16
+ QUDT (``qudt:conversionOffset``), carries that offset in the unit. In
17
+ pint terms, a Celsius temperature is a *point* expressed in ``degC`` and
18
+ a temperature difference is expressed in ``delta_degC`` or ``K`` (OM's
19
+ ``Point`` and ``Measure``; QUDT's *delta quantity*). pint refuses
20
+ ambiguous operations such as adding two temperature points
21
+ (:class:`pint.errors.OffsetUnitCalculusError`).
22
+
23
+ Quantity kinds (thermodynamic temperature, Celsius temperature,
24
+ temperature difference...) come with the bundled QUDT and CF subsets.
25
+ ``docs/rosetta.md`` maps this vocabulary across QUDT, the SI, pint, SymPy,
26
+ CF and OM.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from enum import Enum
32
+ from typing import Any
33
+
34
+ import pint
35
+
36
+ __all__ = ["Q_", "ScaleType", "convert", "is_offset_unit", "ureg"]
37
+
38
+ #: The registry used by nomogram. Created once; never modified afterwards.
39
+ ureg: pint.UnitRegistry = pint.UnitRegistry()
40
+
41
+ #: Shortcut for building quantities in nomogram's registry.
42
+ Q_ = ureg.Quantity
43
+
44
+
45
+ class ScaleType(Enum):
46
+ """Type of quantity-value scale (VIM 1.27; QUDT ``qudt:ScaleType``).
47
+
48
+ A ratio scale has a natural zero (kelvin, pascal): ratios of values are
49
+ meaningful. An interval scale has a conventional origin (Celsius
50
+ temperature): only differences are meaningful, and its values are
51
+ points relative to that origin. QUDT also has ordinal and nominal
52
+ scales; formulas do not need them, so they are left out.
53
+ """
54
+
55
+ RATIO = "ratio"
56
+ INTERVAL = "interval"
57
+
58
+
59
+ def is_offset_unit(unit: object) -> bool:
60
+ """Tell whether a unit carries an offset, i.e. expresses points of an interval scale.
61
+
62
+ A unit carries an offset (QUDT ``conversionOffset``; pint's
63
+ non-multiplicative units) when its zero is not the zero of the
64
+ corresponding base unit: 0 degC is 273.15 K, whereas 0 delta_degC is
65
+ 0 K. The test relies on pint's public conversion only.
66
+
67
+ Parameters
68
+ ----------
69
+ unit : str or pint.Unit
70
+ Unit name or unit object; it is read by name in nomogram's
71
+ registry.
72
+
73
+ Returns
74
+ -------
75
+ bool
76
+ ``True`` for units such as ``degC`` or ``degF``; ``False`` for
77
+ multiplicative units such as ``K``, ``Pa`` or ``delta_degC``.
78
+ """
79
+ zero = Q_(0.0, str(unit)).to_base_units().magnitude
80
+ return bool(zero != 0.0)
81
+
82
+
83
+ def convert(quantity: Any, unit: str) -> Any:
84
+ """Convert a quantity value to ``unit``, accepting other pint registries.
85
+
86
+ Values built with another pint registry (MetPy's, for instance) are
87
+ first rebuilt in nomogram's registry from their magnitude and unit
88
+ name, so that pint does not reject the mix of registries.
89
+
90
+ Parameters
91
+ ----------
92
+ quantity : pint.Quantity
93
+ The quantity value to convert (pint's name for it is kept); its
94
+ magnitude may be a number or a numpy array.
95
+ unit : str
96
+ Target unit, for example ``"K"``, ``"hPa"`` or ``"delta_degC"``.
97
+
98
+ Returns
99
+ -------
100
+ pint.Quantity
101
+ The converted quantity value, in nomogram's registry.
102
+
103
+ Raises
104
+ ------
105
+ pint.errors.DimensionalityError
106
+ If the target unit does not have the dimension of the value.
107
+ TypeError
108
+ If ``quantity`` is not a pint quantity.
109
+ """
110
+ if not isinstance(quantity, pint.Quantity):
111
+ msg = f"expected a pint quantity, got {type(quantity).__name__}"
112
+ raise TypeError(msg)
113
+ native = (
114
+ quantity
115
+ if quantity._REGISTRY is ureg
116
+ else Q_(quantity.magnitude, str(quantity.units))
117
+ )
118
+ return native.to(unit)