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/__init__.py +17 -0
- nomogram/catalog.py +533 -0
- nomogram/comparison.py +305 -0
- nomogram/data/NOTICE.md +25 -0
- nomogram/data/catalog/humidity.toml +534 -0
- nomogram/data/semantics.json +464 -0
- nomogram/expression.py +232 -0
- nomogram/model.py +594 -0
- nomogram/plot.py +176 -0
- nomogram/py.typed +0 -0
- nomogram/semantics.py +333 -0
- nomogram/units.py +118 -0
- nomogram-0.1.0.dist-info/METADATA +125 -0
- nomogram-0.1.0.dist-info/RECORD +17 -0
- nomogram-0.1.0.dist-info/WHEEL +4 -0
- nomogram-0.1.0.dist-info/licenses/LICENSE +28 -0
- nomogram-0.1.0.dist-info/licenses/src/nomogram/data/NOTICE.md +25 -0
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)
|