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/comparison.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Miki
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""Comparing alternative formulations, and chaining them.
|
|
5
|
+
|
|
6
|
+
:func:`compare` evaluates formulations of the same quantity kind on the
|
|
7
|
+
same inputs and gives their relative deviation from a reference
|
|
8
|
+
formulation, in one vectorised pass: the comparison table of the
|
|
9
|
+
reference use case.
|
|
10
|
+
|
|
11
|
+
:func:`compose` feeds the output of formulations into the inputs of
|
|
12
|
+
another, for instance a saturation vapour pressure formulation into the
|
|
13
|
+
ideal gas mass concentration of water vapour, to compare HA = f(T, RH)
|
|
14
|
+
across vapour pressure formulations.
|
|
15
|
+
|
|
16
|
+
Inputs are matched by quantity kind. The Celsius temperature and the
|
|
17
|
+
thermodynamic temperature are the only distinct kinds treated as the same
|
|
18
|
+
input: they are two expressions of one state, t = T - 273.15 K (SI
|
|
19
|
+
Brochure, section 2.3.1), and pint converts between them. A temperature
|
|
20
|
+
difference is never matched with either.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Iterable, Mapping
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from nomogram.model import Basis, Outside, ScaleConversion, Variable
|
|
32
|
+
|
|
33
|
+
__all__ = ["Comparison", "Composition", "Evaluable", "compare", "compose"]
|
|
34
|
+
|
|
35
|
+
#: Kinds that express the same temperature state (SI Brochure 2.3.1).
|
|
36
|
+
TEMPERATURE_POINTS = frozenset({"ThermodynamicTemperature", "CelsiusTemperature"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Evaluable(Protocol):
|
|
40
|
+
"""What :func:`compare` and :func:`compose` need from a formulation."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def key(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def output(self) -> Variable: ...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def inputs(self) -> tuple[Variable, ...]: ...
|
|
50
|
+
|
|
51
|
+
def evaluate(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
outside: Outside = "nan",
|
|
55
|
+
basis: Basis = "stated",
|
|
56
|
+
scale: ScaleConversion = "convert",
|
|
57
|
+
**inputs: Any,
|
|
58
|
+
) -> Any: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def same_state(kind_a: str, kind_b: str) -> bool:
|
|
62
|
+
"""Whether two quantity kinds may feed the same input."""
|
|
63
|
+
return kind_a == kind_b or {kind_a, kind_b} <= TEMPERATURE_POINTS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _arguments(formulation: Evaluable, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
67
|
+
arguments = {}
|
|
68
|
+
for variable in formulation.inputs:
|
|
69
|
+
matches = [kind for kind in values if same_state(kind, variable.kind)]
|
|
70
|
+
if len(matches) != 1:
|
|
71
|
+
found = "none" if not matches else ", ".join(sorted(matches))
|
|
72
|
+
msg = (
|
|
73
|
+
f"{formulation.key}: input {variable.identifier} ({variable.kind}) "
|
|
74
|
+
f"needs exactly one value of a matching kind; found: {found}"
|
|
75
|
+
)
|
|
76
|
+
raise ValueError(msg)
|
|
77
|
+
arguments[variable.identifier] = values[matches[0]]
|
|
78
|
+
return arguments
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Comparison:
|
|
83
|
+
"""Values of alternative formulations and their deviation from a reference.
|
|
84
|
+
|
|
85
|
+
Attributes
|
|
86
|
+
----------
|
|
87
|
+
reference : str
|
|
88
|
+
Key of the reference formulation.
|
|
89
|
+
inputs : mapping of str to pint.Quantity
|
|
90
|
+
Input values, by quantity kind.
|
|
91
|
+
values : mapping of str to pint.Quantity
|
|
92
|
+
Result of each formulation, in the reference's output unit.
|
|
93
|
+
relative_deviations : mapping of str to numpy.ndarray
|
|
94
|
+
``value / reference - 1`` for each formulation; NaN where either is
|
|
95
|
+
outside its validity domain (with the default ``outside="nan"``).
|
|
96
|
+
formulations : mapping of str to Formulation or Composition
|
|
97
|
+
The formulations compared, by key (labels, output variable).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
reference: str
|
|
101
|
+
inputs: Mapping[str, Any]
|
|
102
|
+
values: Mapping[str, Any]
|
|
103
|
+
relative_deviations: Mapping[str, Any]
|
|
104
|
+
formulations: Mapping[str, Evaluable] = field(default_factory=dict, repr=False)
|
|
105
|
+
|
|
106
|
+
def max_abs_deviation(self) -> dict[str, float]:
|
|
107
|
+
"""Largest absolute relative deviation of each formulation (NaN ignored)."""
|
|
108
|
+
result = {}
|
|
109
|
+
for key, deviation in self.relative_deviations.items():
|
|
110
|
+
finite = np.abs(np.asarray(deviation, dtype=float))
|
|
111
|
+
finite = finite[np.isfinite(finite)]
|
|
112
|
+
result[key] = float(finite.max()) if finite.size else float("nan")
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compare(
|
|
117
|
+
formulations: Iterable[Evaluable],
|
|
118
|
+
reference: Evaluable,
|
|
119
|
+
inputs: Mapping[str, Any],
|
|
120
|
+
*,
|
|
121
|
+
outside: Outside = "nan",
|
|
122
|
+
basis: Basis = "stated",
|
|
123
|
+
scale: ScaleConversion = "convert",
|
|
124
|
+
) -> Comparison:
|
|
125
|
+
"""Compare formulations of one quantity kind against a reference.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
formulations : iterable of Formulation or Composition
|
|
130
|
+
The formulations to compare; the reference may be among them.
|
|
131
|
+
reference : Formulation or Composition
|
|
132
|
+
The formulation deviations are measured against.
|
|
133
|
+
inputs : mapping of str to pint.Quantity
|
|
134
|
+
One value per quantity kind, for example
|
|
135
|
+
``{"ThermodynamicTemperature": Q_(grid, "degC")}``; numpy arrays
|
|
136
|
+
give the whole table in one pass.
|
|
137
|
+
outside, basis, scale
|
|
138
|
+
Passed to each evaluation (see
|
|
139
|
+
:meth:`nomogram.model.Formulation.evaluate`). ``"nan"`` keeps
|
|
140
|
+
each formulation to its own validity domains.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
Comparison
|
|
145
|
+
Values and relative deviations, by formulation key.
|
|
146
|
+
|
|
147
|
+
Raises
|
|
148
|
+
------
|
|
149
|
+
ValueError
|
|
150
|
+
If a formulation computes another kind than the reference, or an
|
|
151
|
+
input cannot be matched to exactly one value.
|
|
152
|
+
"""
|
|
153
|
+
kind = reference.output.kind
|
|
154
|
+
unit = None
|
|
155
|
+
values: dict[str, Any] = {}
|
|
156
|
+
candidates = [reference, *(f for f in formulations if f.key != reference.key)]
|
|
157
|
+
for formulation in candidates:
|
|
158
|
+
if formulation.output.kind != kind:
|
|
159
|
+
msg = f"{formulation.key} computes {formulation.output.kind}, not {kind}"
|
|
160
|
+
raise ValueError(msg)
|
|
161
|
+
result = formulation.evaluate(
|
|
162
|
+
outside=outside, basis=basis, scale=scale, **_arguments(formulation, inputs)
|
|
163
|
+
)
|
|
164
|
+
unit = unit or result.units
|
|
165
|
+
values[formulation.key] = result.to(unit)
|
|
166
|
+
base = np.asarray(values[reference.key].magnitude, dtype=float)
|
|
167
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
168
|
+
deviations = {
|
|
169
|
+
key: np.asarray(value.magnitude, dtype=float) / base - 1.0
|
|
170
|
+
for key, value in values.items()
|
|
171
|
+
}
|
|
172
|
+
compared = {f.key: f for f in candidates}
|
|
173
|
+
return Comparison(reference.key, dict(inputs), values, deviations, compared)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class Composition:
|
|
178
|
+
"""A formulation whose inputs are partly computed by other formulations.
|
|
179
|
+
|
|
180
|
+
Attributes
|
|
181
|
+
----------
|
|
182
|
+
outer : Formulation
|
|
183
|
+
The formulation evaluated last.
|
|
184
|
+
inner : mapping of str to Formulation
|
|
185
|
+
For some inputs of ``outer``, by identifier, the formulation
|
|
186
|
+
computing them.
|
|
187
|
+
bind : mapping of str to str
|
|
188
|
+
Inputs of inner formulations taken from inputs of ``outer``, by
|
|
189
|
+
identifier (``{"t": "T"}``).
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
outer: Evaluable
|
|
193
|
+
inner: Mapping[str, Evaluable]
|
|
194
|
+
bind: Mapping[str, str]
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def key(self) -> str:
|
|
198
|
+
"""``outer[input=inner,...]``, for example ``ha[e_s=sonntag1990]``."""
|
|
199
|
+
parts = ",".join(f"{k}={f.key}" for k, f in sorted(self.inner.items()))
|
|
200
|
+
return f"{self.outer.key}[{parts}]"
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def output(self) -> Variable:
|
|
204
|
+
"""The output of the outer formulation."""
|
|
205
|
+
return self.outer.output
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def inputs(self) -> tuple[Variable, ...]:
|
|
209
|
+
"""Inputs to supply: those of ``outer`` not computed, then unbound inner ones."""
|
|
210
|
+
own = tuple(v for v in self.outer.inputs if v.identifier not in self.inner)
|
|
211
|
+
names = {v.identifier for v in own}
|
|
212
|
+
extra = []
|
|
213
|
+
for formulation in self.inner.values():
|
|
214
|
+
for variable in formulation.inputs:
|
|
215
|
+
if (
|
|
216
|
+
variable.identifier not in self.bind
|
|
217
|
+
and variable.identifier not in names
|
|
218
|
+
):
|
|
219
|
+
extra.append(variable)
|
|
220
|
+
names.add(variable.identifier)
|
|
221
|
+
return own + tuple(extra)
|
|
222
|
+
|
|
223
|
+
def evaluate(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
outside: Outside = "nan",
|
|
227
|
+
basis: Basis = "stated",
|
|
228
|
+
scale: ScaleConversion = "convert",
|
|
229
|
+
**inputs: Any,
|
|
230
|
+
) -> Any:
|
|
231
|
+
"""Evaluate inner formulations, then the outer one.
|
|
232
|
+
|
|
233
|
+
Parameters are those of :meth:`nomogram.model.Formulation.evaluate`;
|
|
234
|
+
``inputs`` are given by the identifiers of :attr:`inputs`.
|
|
235
|
+
"""
|
|
236
|
+
computed = dict(inputs)
|
|
237
|
+
for identifier, formulation in self.inner.items():
|
|
238
|
+
arguments = {
|
|
239
|
+
v.identifier: inputs[self.bind.get(v.identifier, v.identifier)]
|
|
240
|
+
for v in formulation.inputs
|
|
241
|
+
}
|
|
242
|
+
computed[identifier] = formulation.evaluate(
|
|
243
|
+
outside=outside, basis=basis, scale=scale, **arguments
|
|
244
|
+
)
|
|
245
|
+
outer_arguments = {
|
|
246
|
+
v.identifier: computed[v.identifier] for v in self.outer.inputs
|
|
247
|
+
}
|
|
248
|
+
return self.outer.evaluate(
|
|
249
|
+
outside=outside, basis=basis, scale=scale, **outer_arguments
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def compose(
|
|
254
|
+
outer: Evaluable,
|
|
255
|
+
inner: Mapping[str, Evaluable],
|
|
256
|
+
bind: Mapping[str, str] | None = None,
|
|
257
|
+
) -> Composition:
|
|
258
|
+
"""Feed the outputs of formulations into the inputs of another.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
outer : Formulation
|
|
263
|
+
The formulation evaluated last.
|
|
264
|
+
inner : mapping of str to Formulation
|
|
265
|
+
For some inputs of ``outer``, by identifier, the formulation that
|
|
266
|
+
computes them; its output kind must be the input's kind.
|
|
267
|
+
bind : mapping of str to str, optional
|
|
268
|
+
Inputs of inner formulations to take from inputs of ``outer``, by
|
|
269
|
+
identifier, for example ``{"t": "T"}``: the binding is declared,
|
|
270
|
+
never guessed, and both kinds must express the same state.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
Composition
|
|
275
|
+
Evaluable like a formulation.
|
|
276
|
+
|
|
277
|
+
Raises
|
|
278
|
+
------
|
|
279
|
+
ValueError
|
|
280
|
+
If an identifier is unknown or kinds do not match.
|
|
281
|
+
"""
|
|
282
|
+
bind = dict(bind or {})
|
|
283
|
+
outer_inputs = {v.identifier: v for v in outer.inputs}
|
|
284
|
+
for identifier, formulation in inner.items():
|
|
285
|
+
target = outer_inputs.get(identifier)
|
|
286
|
+
if target is None:
|
|
287
|
+
msg = f"{outer.key} has no input {identifier!r}"
|
|
288
|
+
raise ValueError(msg)
|
|
289
|
+
if formulation.output.kind != target.kind:
|
|
290
|
+
msg = (
|
|
291
|
+
f"{formulation.key} computes {formulation.output.kind}, "
|
|
292
|
+
f"but {identifier} is {target.kind}"
|
|
293
|
+
)
|
|
294
|
+
raise ValueError(msg)
|
|
295
|
+
inner_inputs = {v.identifier: v for f in inner.values() for v in f.inputs}
|
|
296
|
+
for source, target_name in bind.items():
|
|
297
|
+
variable = inner_inputs.get(source)
|
|
298
|
+
target = outer_inputs.get(target_name)
|
|
299
|
+
if variable is None or target is None or target_name in inner:
|
|
300
|
+
msg = f"cannot bind {source!r} to {target_name!r}"
|
|
301
|
+
raise ValueError(msg)
|
|
302
|
+
if not same_state(variable.kind, target.kind):
|
|
303
|
+
msg = f"cannot bind {source} ({variable.kind}) to {target_name} ({target.kind})"
|
|
304
|
+
raise ValueError(msg)
|
|
305
|
+
return Composition(outer, dict(inner), bind)
|
nomogram/data/NOTICE.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Third-party data bundled with nomogram
|
|
2
|
+
|
|
3
|
+
`semantics.json` is generated by `scripts/extract_semantics.py` from the
|
|
4
|
+
sources below. Never edit it by hand.
|
|
5
|
+
|
|
6
|
+
## QUDT
|
|
7
|
+
|
|
8
|
+
Quantity kinds and unit definitions are extracted from
|
|
9
|
+
[QUDT](https://qudt.org/) release 3.5.2, © QUDT.org, licensed under the
|
|
10
|
+
[Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/)
|
|
11
|
+
(CC BY 4.0).
|
|
12
|
+
|
|
13
|
+
Changes: a subset of quantity kinds and units was selected and
|
|
14
|
+
restructured as JSON; nomogram adds the scale type and point/difference
|
|
15
|
+
typing of each kind, the units QUDT does not declare for some kinds
|
|
16
|
+
(`added_units`), the pint expression of each unit, and the link from
|
|
17
|
+
each kind to CF standard names. Links from QUDT's `wikidataMatch` are
|
|
18
|
+
recorded as SKOS close matches, with the extraction date as their
|
|
19
|
+
verification date.
|
|
20
|
+
|
|
21
|
+
## CF standard names
|
|
22
|
+
|
|
23
|
+
Standard names and canonical units are extracted from the
|
|
24
|
+
[CF standard name table](https://cfconventions.org/) version 95,
|
|
25
|
+
published under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|