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