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/model.py ADDED
@@ -0,0 +1,594 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Formula model: formulations, their variables, coefficients and sources.
5
+
6
+ A *formulation* is one author's way of computing a quantity kind (the
7
+ Magnus form with Sonntag's 1990 coefficients, for instance). Its
8
+ mathematical content is an expression written once (see
9
+ :mod:`nomogram.expression`); everything around it is traced: variables
10
+ with their quantity kind and unit, empirical coefficients, physical
11
+ constants, objective validity domains, accuracy and references.
12
+
13
+ Two kinds of formulas are told apart by ``fixed_units``:
14
+
15
+ - *fixed units*: the coefficients only hold in the declared units (the
16
+ Magnus form takes t in degC and gives hPa). Coefficients are never
17
+ converted; inputs are converted to the declared unit **and scale**
18
+ before evaluation (a Fahrenheit point becomes a Celsius point), which
19
+ makes the formula usable from any unit;
20
+ - otherwise, a quantity equation whose dimensional homogeneity is checked
21
+ when the catalog is loaded.
22
+
23
+ Validity domains are objective only: physical, or stated by the author or
24
+ the cited publication, each with its criterion and reference. What to do
25
+ outside them is a parameter of :meth:`Formulation.evaluate`; by default
26
+ the result is NaN there. See ``docs/rosetta.md`` for the vocabulary.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import warnings
32
+ from collections.abc import Callable, Mapping
33
+ from dataclasses import dataclass
34
+ from enum import Enum
35
+ from functools import cached_property
36
+ from typing import Any, Literal
37
+
38
+ import numpy as np
39
+
40
+ from nomogram.expression import compile_numeric, parse_expression, to_latex
41
+ from nomogram.semantics import ExternalMatch, semantic_catalog
42
+ from nomogram.units import Q_, convert
43
+
44
+ __all__ = [
45
+ "Coefficient",
46
+ "DomainBasis",
47
+ "Formulation",
48
+ "Notation",
49
+ "OutOfDomainError",
50
+ "PhysicalConstant",
51
+ "Reference",
52
+ "ReferenceStatus",
53
+ "ScaleBasis",
54
+ "TemperatureScale",
55
+ "TradeMeaning",
56
+ "TradeTerm",
57
+ "ValidityDomain",
58
+ "ValidityWarning",
59
+ "Variable",
60
+ ]
61
+
62
+ #: What :meth:`Formulation.evaluate` does with values outside the domains.
63
+ Outside = Literal["nan", "warn", "raise", "ignore"]
64
+ #: Which validity domains :meth:`Formulation.evaluate` applies.
65
+ Basis = Literal["stated", "physical", "all"]
66
+
67
+ #: Whether :meth:`Formulation.evaluate` converts temperatures between scales.
68
+ ScaleConversion = Literal["convert", "ignore"]
69
+
70
+ #: Relative tolerance at domain bounds, for rounding in unit conversions.
71
+ BOUND_TOLERANCE = 1e-9
72
+
73
+
74
+ class ReferenceStatus(Enum):
75
+ """Whether a reference has been checked against its source."""
76
+
77
+ VERIFIED = "verified"
78
+ TO_CONFIRM = "to-confirm"
79
+
80
+
81
+ class DomainBasis(Enum):
82
+ """Ground of a validity domain."""
83
+
84
+ #: Where the underlying physics holds.
85
+ PHYSICAL = "physical"
86
+ #: Range stated by the author or the cited publication.
87
+ STATED = "stated"
88
+
89
+
90
+ class ValidityWarning(UserWarning):
91
+ """A formulation was evaluated outside its validity domain."""
92
+
93
+
94
+ class OutOfDomainError(ValueError):
95
+ """A formulation was evaluated outside its validity domain."""
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Reference:
100
+ """A bibliographic source.
101
+
102
+ Attributes
103
+ ----------
104
+ key : str
105
+ Catalog key, for example ``"sonntag1990"``.
106
+ authors : str
107
+ Authors, as cited.
108
+ year : int or None
109
+ Year of publication.
110
+ title : str
111
+ Title.
112
+ container : str or None
113
+ Journal, book or series.
114
+ doi : str or None
115
+ DOI, which takes precedence over any other identifier.
116
+ url : str or None
117
+ Address, when there is no DOI.
118
+ status : ReferenceStatus
119
+ Whether the details were checked against the source.
120
+ note : str or None
121
+ What remains to confirm, or any remark.
122
+ matches : tuple of ExternalMatch
123
+ Links to external vocabularies (a Wikidata item...).
124
+ """
125
+
126
+ key: str
127
+ authors: str
128
+ year: int | None
129
+ title: str
130
+ container: str | None
131
+ doi: str | None
132
+ url: str | None
133
+ status: ReferenceStatus
134
+ note: str | None
135
+ matches: tuple[ExternalMatch, ...]
136
+
137
+
138
+ class ScaleBasis(Enum):
139
+ """How the temperature scale of a formulation is known."""
140
+
141
+ #: Stated by the source.
142
+ STATED = "stated"
143
+ #: Inferred from the publication date only: the scale in force then.
144
+ #: Weak, since new scales took years to be applied (ITS-90 included).
145
+ INFERRED_FROM_DATE = "inferred-from-date"
146
+ #: Inferred from numerical evidence, for instance the agreement with a
147
+ #: formulation on a known scale; the evidence goes in the note.
148
+ INFERRED_FROM_NUMBERS = "inferred-from-numbers"
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class TemperatureScale:
153
+ """An international temperature scale, and how it differs from ITS-90.
154
+
155
+ Formulations were fitted to temperatures on the scale in force at the
156
+ time (IPTS-68, ITS-48...). Inputs are taken on ITS-90, today's scale,
157
+ and converted to the scale of the formulation before evaluation.
158
+
159
+ Attributes
160
+ ----------
161
+ key : str
162
+ Catalog key, for example ``"ipts68"``.
163
+ label : str
164
+ Name of the scale.
165
+ t90 : tuple of float
166
+ Celsius temperatures on ITS-90 where the difference is tabulated;
167
+ empty for ITS-90 itself.
168
+ difference : tuple of float
169
+ ``t90 - t_scale`` at those temperatures, in kelvin.
170
+ reference : str or None
171
+ Key of the source of the table.
172
+ """
173
+
174
+ key: str
175
+ label: str
176
+ t90: tuple[float, ...]
177
+ difference: tuple[float, ...]
178
+ reference: str | None
179
+
180
+ def from_its90(self, celsius: Any) -> tuple[Any, Any]:
181
+ """Celsius temperatures on this scale, from ITS-90 ones.
182
+
183
+ Parameters
184
+ ----------
185
+ celsius : array_like
186
+ Celsius temperatures on ITS-90.
187
+
188
+ Returns
189
+ -------
190
+ (numpy.ndarray, numpy.ndarray of bool)
191
+ The temperatures on this scale (linear interpolation of the
192
+ table), and where they fall outside the table (NaN there).
193
+ """
194
+ values = np.asarray(celsius, dtype=float)
195
+ if not self.t90:
196
+ return values, np.zeros(values.shape, dtype=bool)
197
+ outside = (values < self.t90[0]) | (values > self.t90[-1])
198
+ shift = np.interp(values, self.t90, self.difference)
199
+ return np.where(outside, np.nan, values - shift), outside
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class Variable:
204
+ """A variable of a formulation: identifier, quantity kind and unit.
205
+
206
+ A variable has no external link of its own: it inherits the links of
207
+ its quantity kind.
208
+
209
+ Attributes
210
+ ----------
211
+ identifier : str
212
+ Semantic identifier used in the expression, for example ``"t"``.
213
+ kind : str
214
+ Name of its quantity kind.
215
+ unit : str
216
+ QUDT name of the unit the formulation works in.
217
+ description : str
218
+ What the variable is, in a few words.
219
+ temperature_scale : TemperatureScale or None
220
+ For a temperature, the scale the formulation was fitted on;
221
+ ``None`` when unknown or not applicable.
222
+ scale_basis : ScaleBasis or None
223
+ How that scale is known.
224
+ """
225
+
226
+ identifier: str
227
+ kind: str
228
+ unit: str
229
+ description: str
230
+ temperature_scale: TemperatureScale | None = None
231
+ scale_basis: ScaleBasis | None = None
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class Coefficient:
236
+ """An empirical coefficient of a formulation (the A, B, C of Magnus).
237
+
238
+ Attributes
239
+ ----------
240
+ identifier : str
241
+ Identifier used in the expression.
242
+ value : float
243
+ Value, as published.
244
+ unit : str or None
245
+ QUDT name of its unit, for documentation; in a fixed-units
246
+ formulation coefficients are numbers tied to the declared units.
247
+ note : str or None
248
+ Remark.
249
+ """
250
+
251
+ identifier: str
252
+ value: float
253
+ unit: str | None
254
+ note: str | None
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class PhysicalConstant:
259
+ """A constant with a metrological status, and its source.
260
+
261
+ Attributes
262
+ ----------
263
+ key : str
264
+ Catalog key, for example ``"R_v_iapws95"``: several sources may
265
+ give different values for the same constant.
266
+ identifier : str
267
+ Identifier used in expressions, for example ``"R_v"``.
268
+ value : float
269
+ Value in ``unit``.
270
+ unit : str
271
+ QUDT name of the unit.
272
+ description : str
273
+ What the constant is.
274
+ reference : str
275
+ Key of the source.
276
+ matches : tuple of ExternalMatch
277
+ Links to external vocabularies.
278
+ """
279
+
280
+ key: str
281
+ identifier: str
282
+ value: float
283
+ unit: str
284
+ description: str
285
+ reference: str
286
+ matches: tuple[ExternalMatch, ...]
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class ValidityDomain:
291
+ """An objective validity domain of a formulation.
292
+
293
+ Attributes
294
+ ----------
295
+ basis : DomainBasis
296
+ Physical, or stated by the author or the cited publication.
297
+ bounds : mapping of str to (float, float)
298
+ Closed interval per input identifier, in the input's unit.
299
+ criterion : str or None
300
+ What the domain guarantees, for example a maximum deviation from
301
+ a reference formulation.
302
+ reference : str or None
303
+ Key of the source of the domain.
304
+ """
305
+
306
+ basis: DomainBasis
307
+ bounds: Mapping[str, tuple[float, float]]
308
+ criterion: str | None
309
+ reference: str | None
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class Notation:
314
+ """How a convention writes identifiers: identifier to LaTeX symbol.
315
+
316
+ Attributes
317
+ ----------
318
+ key : str
319
+ Catalog key, for example ``"wmo"``.
320
+ label : str
321
+ Name of the convention.
322
+ symbols : mapping of str to str
323
+ LaTeX symbol per identifier; identifiers not listed keep their
324
+ default rendering.
325
+ reference : str or None
326
+ Key of the source of the convention.
327
+ """
328
+
329
+ key: str
330
+ label: str
331
+ symbols: Mapping[str, str]
332
+ reference: str | None
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class TradeMeaning:
337
+ """One meaning of an ambiguous trade term.
338
+
339
+ Attributes
340
+ ----------
341
+ community : str
342
+ Who uses the term with this meaning.
343
+ kind : str
344
+ Name of the quantity kind it then denotes.
345
+ definition : str
346
+ The meaning, in plain words.
347
+ sources : tuple of str
348
+ Keys of the references documenting this use.
349
+ """
350
+
351
+ community: str
352
+ kind: str
353
+ definition: str
354
+ sources: tuple[str, ...]
355
+
356
+
357
+ @dataclass(frozen=True)
358
+ class TradeTerm:
359
+ """A term whose meaning depends on the trade: never a quantity kind itself.
360
+
361
+ Attributes
362
+ ----------
363
+ key : str
364
+ Catalog key.
365
+ term : str
366
+ The term, for example ``"absolute humidity"``.
367
+ language : str
368
+ Language code, for example ``"en"``.
369
+ meanings : tuple of TradeMeaning
370
+ Its meanings, each pointing to an unambiguous quantity kind.
371
+ matches : tuple of ExternalMatch
372
+ Links to external vocabularies.
373
+ """
374
+
375
+ key: str
376
+ term: str
377
+ language: str
378
+ meanings: tuple[TradeMeaning, ...]
379
+ matches: tuple[ExternalMatch, ...]
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class Formulation:
384
+ """One author's way of computing a quantity kind.
385
+
386
+ Attributes
387
+ ----------
388
+ key : str
389
+ Catalog key, for example ``"sonntag1990"``.
390
+ label : str
391
+ Name of the formulation.
392
+ output : Variable
393
+ The computed variable; its kind groups alternative formulations.
394
+ inputs : tuple of Variable
395
+ Input variables, in the order of the numeric function.
396
+ coefficients : tuple of Coefficient
397
+ Empirical coefficients.
398
+ constants : tuple of PhysicalConstant
399
+ Physical constants used.
400
+ expression : str
401
+ The expression, in restricted Python syntax.
402
+ fixed_units : bool
403
+ Whether the formulation only holds in its declared units.
404
+ domains : tuple of ValidityDomain
405
+ Objective validity domains.
406
+ accuracy : str or None
407
+ Accuracy claimed by the source, as stated.
408
+ references : tuple of str
409
+ Keys of the references.
410
+ note : str or None
411
+ Remark.
412
+ matches : tuple of ExternalMatch
413
+ Links to external vocabularies (Wikidata item of the formula...).
414
+ """
415
+
416
+ key: str
417
+ label: str
418
+ output: Variable
419
+ inputs: tuple[Variable, ...]
420
+ coefficients: tuple[Coefficient, ...]
421
+ constants: tuple[PhysicalConstant, ...]
422
+ expression: str
423
+ fixed_units: bool
424
+ domains: tuple[ValidityDomain, ...]
425
+ accuracy: str | None
426
+ references: tuple[str, ...]
427
+ note: str | None
428
+ matches: tuple[ExternalMatch, ...]
429
+
430
+ @property
431
+ def names(self) -> tuple[str, ...]:
432
+ """Identifiers of the numeric function's arguments, in order."""
433
+ return (
434
+ tuple(v.identifier for v in self.inputs)
435
+ + tuple(c.identifier for c in self.coefficients)
436
+ + tuple(k.identifier for k in self.constants)
437
+ )
438
+
439
+ @cached_property
440
+ def sympy_expression(self) -> Any:
441
+ """The expression as a SymPy expression."""
442
+ return parse_expression(self.expression, self.names)
443
+
444
+ @cached_property
445
+ def _function(self) -> Callable[..., Any]:
446
+ return compile_numeric(self.sympy_expression, self.names)
447
+
448
+ @cached_property
449
+ def output_factor(self) -> float:
450
+ """Factor bringing the raw result into the output unit.
451
+
452
+ 1 for a fixed-units formulation. For a quantity equation, the
453
+ expression is evaluated once with pint quantities, which checks
454
+ its dimensional homogeneity.
455
+
456
+ Raises
457
+ ------
458
+ pint.errors.DimensionalityError
459
+ If the expression does not have the dimension of the output.
460
+ """
461
+ if self.fixed_units:
462
+ return 1.0
463
+ units = semantic_catalog().units
464
+ ones: list[Any] = [Q_(1.0, units[v.unit].pint) for v in self.inputs]
465
+ ones += [1.0 for _ in self.coefficients]
466
+ ones += [Q_(1.0, units[k.unit].pint) for k in self.constants]
467
+ result = Q_(self._function(*ones))
468
+ return float(result.to(units[self.output.unit].pint).magnitude)
469
+
470
+ def latex(self, notation: Notation | None = None) -> str:
471
+ """The formulation as a LaTeX equation, in a given notation."""
472
+ import sympy
473
+
474
+ symbols = dict(notation.symbols) if notation else {}
475
+ output = sympy.Symbol(self.output.identifier, real=True)
476
+ left = to_latex(output, symbols)
477
+ return f"{left} = {to_latex(self.sympy_expression, symbols)}"
478
+
479
+ def outside_mask(self, values: Mapping[str, Any], basis: Basis = "stated") -> Any:
480
+ """Where input values fall outside the selected validity domains.
481
+
482
+ Parameters
483
+ ----------
484
+ values : mapping of str to array_like
485
+ Input values, by identifier, in the inputs' units.
486
+ basis : {"stated", "physical", "all"}
487
+ Which domains apply.
488
+
489
+ Returns
490
+ -------
491
+ numpy.ndarray of bool
492
+ ``True`` where a value lies outside a selected domain.
493
+
494
+ Notes
495
+ -----
496
+ Bounds are closed and tolerate a relative rounding of
497
+ :data:`BOUND_TOLERANCE`, so that a value converted between units
498
+ (0.01 degC is 273.15999999... K) is not rejected at a bound.
499
+ """
500
+ arrays = {k: np.asarray(v, dtype=float) for k, v in values.items()}
501
+ shape = np.broadcast_shapes(*(a.shape for a in arrays.values()))
502
+ outside = np.zeros(shape, dtype=bool)
503
+ for domain in self.domains:
504
+ if basis != "all" and domain.basis.value != basis:
505
+ continue
506
+ for identifier, (low, high) in domain.bounds.items():
507
+ value = arrays[identifier]
508
+ margin = BOUND_TOLERANCE * max(abs(low), abs(high), 1.0)
509
+ outside |= (value < low - margin) | (value > high + margin)
510
+ return outside
511
+
512
+ def evaluate(
513
+ self,
514
+ *,
515
+ outside: Outside = "nan",
516
+ basis: Basis = "stated",
517
+ scale: ScaleConversion = "convert",
518
+ **inputs: Any,
519
+ ) -> Any:
520
+ """Evaluate the formulation on pint quantities.
521
+
522
+ Inputs are converted to the unit and scale of each input variable,
523
+ then the numeric function runs, vectorised with numpy.
524
+
525
+ Parameters
526
+ ----------
527
+ outside : {"nan", "warn", "raise", "ignore"}
528
+ Behaviour outside the validity domains: NaN there (default),
529
+ compute and warn, raise, or compute silently (comparisons).
530
+ basis : {"stated", "physical", "all"}
531
+ Which domains apply (default: those stated by the sources).
532
+ scale : {"convert", "ignore"}
533
+ Temperatures are taken on ITS-90. With ``"convert"`` (default)
534
+ they are converted to the temperature scale the formulation
535
+ was fitted on, when it is known (NaN beyond the conversion
536
+ table); ``"ignore"`` uses them as they are.
537
+ **inputs : pint.Quantity
538
+ One quantity per input variable, by identifier.
539
+
540
+ Returns
541
+ -------
542
+ pint.Quantity
543
+ The result, in the output unit.
544
+
545
+ Raises
546
+ ------
547
+ TypeError
548
+ If an input is missing or unexpected.
549
+ OutOfDomainError
550
+ With ``outside="raise"``, if a value is outside a domain.
551
+ """
552
+ expected = {v.identifier for v in self.inputs}
553
+ if set(inputs) != expected:
554
+ missing = ", ".join(sorted(expected - set(inputs))) or "none"
555
+ extra = ", ".join(sorted(set(inputs) - expected)) or "none"
556
+ msg = f"{self.key}: missing inputs: {missing}; unexpected: {extra}"
557
+ raise TypeError(msg)
558
+ units = semantic_catalog().units
559
+ values = {
560
+ v.identifier: np.asarray(
561
+ convert(inputs[v.identifier], units[v.unit].pint).magnitude,
562
+ dtype=float,
563
+ )
564
+ for v in self.inputs
565
+ }
566
+ if scale == "convert":
567
+ for v in self.inputs:
568
+ if v.temperature_scale is None or not v.temperature_scale.t90:
569
+ continue
570
+ celsius = convert(inputs[v.identifier], "degC").magnitude
571
+ on_scale, _ = v.temperature_scale.from_its90(celsius)
572
+ values[v.identifier] = np.asarray(
573
+ convert(Q_(on_scale, "degC"), units[v.unit].pint).magnitude,
574
+ dtype=float,
575
+ )
576
+ arguments: list[Any] = [values[v.identifier] for v in self.inputs]
577
+ arguments += [c.value for c in self.coefficients]
578
+ arguments += [k.value for k in self.constants]
579
+ with np.errstate(invalid="ignore", divide="ignore"):
580
+ result = np.asarray(self._function(*arguments), dtype=float)
581
+ result = result * self.output_factor
582
+ mask = self.outside_mask(values, basis)
583
+ if mask.any() and outside != "ignore":
584
+ message = (
585
+ f"{self.key}: {int(mask.sum())} value(s) outside its {basis} domain"
586
+ )
587
+ if outside == "raise":
588
+ raise OutOfDomainError(message)
589
+ if outside == "warn":
590
+ warnings.warn(message, ValidityWarning, stacklevel=2)
591
+ else:
592
+ result = np.where(mask, np.nan, result)
593
+ magnitude = result.item() if result.ndim == 0 else result
594
+ return Q_(magnitude, units[self.output.unit].pint)