nomogram 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → nomogram-0.2.0}/PKG-INFO +5 -2
- {nomogram-0.1.1 → nomogram-0.2.0}/README.md +4 -1
- {nomogram-0.1.1 → nomogram-0.2.0}/pyproject.toml +1 -1
- {nomogram-0.1.1 → nomogram-0.2.0}/pyproject.toml.orig +1 -1
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/__init__.py +2 -2
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/catalog.py +3 -1
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/comparison.py +25 -3
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/data/catalog/humidity.toml +273 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/data/semantics.json +49 -0
- nomogram-0.2.0/src/nomogram/inversion.py +480 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/model.py +58 -6
- {nomogram-0.1.1 → nomogram-0.2.0}/LICENSE +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/data/NOTICE.md +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/expression.py +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/plot.py +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/py.typed +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/semantics.py +0 -0
- {nomogram-0.1.1 → nomogram-0.2.0}/src/nomogram/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nomogram
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Repository of scientific formulas: notations, units, validity domains
|
|
5
5
|
Keywords: scientific computing,formulas,units,dimensional analysis,physics,thermodynamics,geoscience,atmospheric science,meteorology,hydrology,psychrometrics
|
|
6
6
|
Author: miki4ia
|
|
@@ -41,6 +41,7 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
[](https://codecov.io/gh/miki4iaml/nomogram)
|
|
42
42
|
[](pyproject.toml)
|
|
43
43
|
[](LICENSE)
|
|
44
|
+
[](https://doi.org/10.5281/zenodo.23373134)
|
|
44
45
|
[](https://github.com/astral-sh/uv)
|
|
45
46
|
[](https://github.com/astral-sh/ruff)
|
|
46
47
|
[](https://mypy-lang.org/)
|
|
@@ -111,7 +112,9 @@ ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
|
|
|
111
112
|
```
|
|
112
113
|
|
|
113
114
|
Each formulation keeps its units, validity domain, temperature scale and
|
|
114
|
-
references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md)
|
|
115
|
+
references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md);
|
|
116
|
+
[How nomogram earns trust](docs/approach.md) explains what is checked and
|
|
117
|
+
how.
|
|
115
118
|
|
|
116
119
|
## Contributing
|
|
117
120
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
[](https://codecov.io/gh/miki4iaml/nomogram)
|
|
8
8
|
[](pyproject.toml)
|
|
9
9
|
[](LICENSE)
|
|
10
|
+
[](https://doi.org/10.5281/zenodo.23373134)
|
|
10
11
|
[](https://github.com/astral-sh/uv)
|
|
11
12
|
[](https://github.com/astral-sh/ruff)
|
|
12
13
|
[](https://mypy-lang.org/)
|
|
@@ -77,7 +78,9 @@ ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
|
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
Each formulation keeps its units, validity domain, temperature scale and
|
|
80
|
-
references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md)
|
|
81
|
+
references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md);
|
|
82
|
+
[How nomogram earns trust](docs/approach.md) explains what is checked and
|
|
83
|
+
how.
|
|
81
84
|
|
|
82
85
|
## Contributing
|
|
83
86
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
Submodules are imported on demand, so that ``import nomogram`` stays
|
|
7
7
|
light: :mod:`nomogram.units` (pint), :mod:`nomogram.expression`
|
|
8
8
|
(SymPy), :mod:`nomogram.semantics` (bundled QUDT and CF subsets),
|
|
9
|
-
:mod:`nomogram.model` and :mod:`nomogram.catalog` (formulations),
|
|
10
|
-
:mod:`nomogram.comparison`.
|
|
9
|
+
:mod:`nomogram.model` and :mod:`nomogram.catalog` (formulations),
|
|
10
|
+
:mod:`nomogram.comparison`, :mod:`nomogram.inversion` and :mod:`nomogram.plot`.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from importlib.metadata import version
|
|
@@ -67,7 +67,9 @@ _SECTIONS = (
|
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
#: Kinds that may carry a temperature scale, and the units allowed for them.
|
|
70
|
-
_SCALED_KINDS = frozenset(
|
|
70
|
+
_SCALED_KINDS = frozenset(
|
|
71
|
+
{"ThermodynamicTemperature", "CelsiusTemperature", "DewPointTemperature"}
|
|
72
|
+
)
|
|
71
73
|
_SCALED_UNITS = frozenset({"K", "DEG_C"})
|
|
72
74
|
|
|
73
75
|
|
|
@@ -30,7 +30,7 @@ import numpy as np
|
|
|
30
30
|
|
|
31
31
|
from nomogram.model import Basis, Outside, ScaleConversion, Variable
|
|
32
32
|
|
|
33
|
-
__all__ = ["Comparison", "Composition", "Evaluable", "compare", "compose"]
|
|
33
|
+
__all__ = ["Comparison", "Composition", "Evaluable", "compare", "compose", "feeds"]
|
|
34
34
|
|
|
35
35
|
#: Kinds that express the same temperature state (SI Brochure 2.3.1).
|
|
36
36
|
TEMPERATURE_POINTS = frozenset({"ThermodynamicTemperature", "CelsiusTemperature"})
|
|
@@ -63,6 +63,27 @@ def same_state(kind_a: str, kind_b: str) -> bool:
|
|
|
63
63
|
return kind_a == kind_b or {kind_a, kind_b} <= TEMPERATURE_POINTS
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def feeds(output_kind: str, input_kind: str) -> bool:
|
|
67
|
+
"""Whether a result of one kind may feed an input of another.
|
|
68
|
+
|
|
69
|
+
It may when the kinds are the same, or when the result's kind
|
|
70
|
+
specializes the input's (QUDT ``specializationOf``, directly or not):
|
|
71
|
+
a saturation vapour pressure is a vapour pressure. Never the other
|
|
72
|
+
way round.
|
|
73
|
+
"""
|
|
74
|
+
from nomogram.catalog import catalog
|
|
75
|
+
|
|
76
|
+
kinds = catalog().kinds
|
|
77
|
+
kind = kinds.get(output_kind)
|
|
78
|
+
seen = set()
|
|
79
|
+
while kind is not None and kind.name not in seen:
|
|
80
|
+
if kind.name == input_kind:
|
|
81
|
+
return True
|
|
82
|
+
seen.add(kind.name)
|
|
83
|
+
kind = kinds.get(kind.specialization_of or "")
|
|
84
|
+
return output_kind == input_kind
|
|
85
|
+
|
|
86
|
+
|
|
66
87
|
def _arguments(formulation: Evaluable, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
67
88
|
arguments = {}
|
|
68
89
|
for variable in formulation.inputs:
|
|
@@ -263,7 +284,8 @@ def compose(
|
|
|
263
284
|
The formulation evaluated last.
|
|
264
285
|
inner : mapping of str to Formulation
|
|
265
286
|
For some inputs of ``outer``, by identifier, the formulation that
|
|
266
|
-
computes them; its output kind must be the input's kind
|
|
287
|
+
computes them; its output kind must be the input's kind or a
|
|
288
|
+
specialization of it (see :func:`feeds`).
|
|
267
289
|
bind : mapping of str to str, optional
|
|
268
290
|
Inputs of inner formulations to take from inputs of ``outer``, by
|
|
269
291
|
identifier, for example ``{"t": "T"}``: the binding is declared,
|
|
@@ -286,7 +308,7 @@ def compose(
|
|
|
286
308
|
if target is None:
|
|
287
309
|
msg = f"{outer.key} has no input {identifier!r}"
|
|
288
310
|
raise ValueError(msg)
|
|
289
|
-
if formulation.output.kind
|
|
311
|
+
if not feeds(formulation.output.kind, target.kind):
|
|
290
312
|
msg = (
|
|
291
313
|
f"{formulation.key} computes {formulation.output.kind}, "
|
|
292
314
|
f"but {identifier} is {target.kind}"
|
|
@@ -17,6 +17,15 @@ matches = [
|
|
|
17
17
|
{ target = "http://www.wikidata.org/entity/Q2387102", relation = "broad", verified = 2026-10-14 },
|
|
18
18
|
]
|
|
19
19
|
|
|
20
|
+
[kinds.SaturationVapourPressureOverIce]
|
|
21
|
+
label = "Saturation Vapour Pressure over Ice"
|
|
22
|
+
specialization_of = "VapourPressure"
|
|
23
|
+
description = "Pressure of water vapour in equilibrium with a plane surface of ice (ice Ih)."
|
|
24
|
+
# The Wikidata item covers saturation over water and over ice: broader.
|
|
25
|
+
matches = [
|
|
26
|
+
{ target = "http://www.wikidata.org/entity/Q2387102", relation = "broad", verified = 2026-10-14 },
|
|
27
|
+
]
|
|
28
|
+
|
|
20
29
|
# --- References -----------------------------------------------------------
|
|
21
30
|
|
|
22
31
|
[references.sonntag1990]
|
|
@@ -154,6 +163,61 @@ doi = "10.1088/0026-1394/27/1/002"
|
|
|
154
163
|
status = "to-confirm"
|
|
155
164
|
note = "Text and Table 6 (differences between ITS-90 and IPTS-68) read in a copy of the text, not in the journal; DOI not checked."
|
|
156
165
|
|
|
166
|
+
[references.ecmwf_ifs_cy49r1]
|
|
167
|
+
authors = "ECMWF"
|
|
168
|
+
year = 2024
|
|
169
|
+
title = "IFS Documentation CY49R1 - Part IV: Physical Processes"
|
|
170
|
+
url = "https://www.ecmwf.int/en/elibrary/81626-ifs-documentation-cy49r1-part-iv-physical-processes"
|
|
171
|
+
status = "verified"
|
|
172
|
+
note = "Chapter 12, section 12.5 (p. 246): Md = 28.9644e-3 kg/mol, Mv = 18.0153e-3 kg/mol, Rd = 287.0597 and Rv = 461.5250 J/(kg K); their ratio Rd/Rv is 0.6219808."
|
|
173
|
+
|
|
174
|
+
[references.metpy171]
|
|
175
|
+
authors = "MetPy developers"
|
|
176
|
+
year = 2025
|
|
177
|
+
title = "MetPy 1.7.1, metpy.constants (src/metpy/constants/default.py)"
|
|
178
|
+
url = "https://github.com/Unidata/MetPy/blob/v1.7.1/src/metpy/constants/default.py"
|
|
179
|
+
status = "verified"
|
|
180
|
+
note = "epsilon = Mw / Md, computed from Mw = 18.015268 g/mol and Md = 28.96546 g/mol (0.6219569); MetPy 0.12 used 18.01528 and 28.9644 g/mol. Released 2025-08-29."
|
|
181
|
+
|
|
182
|
+
[references.iapws_r14_08]
|
|
183
|
+
authors = "IAPWS"
|
|
184
|
+
year = 2011
|
|
185
|
+
title = "Revised Release on the Pressure along the Melting and Sublimation Curves of Ordinary Water Substance (R14-08(2011))"
|
|
186
|
+
url = "https://iapws.org/technical-guidance/release/MeltSub"
|
|
187
|
+
status = "verified"
|
|
188
|
+
note = "Equation (6), coefficients, range (50 K to 273.16 K, ITS-90), uncertainty (Fig. 2, Eqs. 7a and 7b) and Table 3 read in the official release."
|
|
189
|
+
|
|
190
|
+
[references.wagner2011]
|
|
191
|
+
authors = "Wagner, W.; Riethmann, T.; Feistel, R.; Harvey, A. H."
|
|
192
|
+
year = 2011
|
|
193
|
+
title = "New equations for the sublimation pressure and melting pressure of H2O ice Ih"
|
|
194
|
+
container = "Journal of Physical and Chemical Reference Data 40, 043103"
|
|
195
|
+
status = "to-confirm"
|
|
196
|
+
note = "Article behind IAPWS R14-08(2011), as cited by the release; not read."
|
|
197
|
+
|
|
198
|
+
[references.wmo2008]
|
|
199
|
+
authors = "World Meteorological Organization"
|
|
200
|
+
year = 2008
|
|
201
|
+
title = "Guide to Meteorological Instruments and Methods of Observation (WMO-No. 8), seventh edition"
|
|
202
|
+
status = "to-confirm"
|
|
203
|
+
note = "Cited by Vömel for the formulations over water and ice; not read."
|
|
204
|
+
|
|
205
|
+
[references.marti_mauersberger1993]
|
|
206
|
+
authors = "Marti, J.; Mauersberger, K."
|
|
207
|
+
year = 1993
|
|
208
|
+
title = "A survey and new measurements of ice vapor pressure at temperatures between 170 and 250 K"
|
|
209
|
+
container = "Geophysical Research Letters 20, 363-366"
|
|
210
|
+
status = "to-confirm"
|
|
211
|
+
note = "Cited by Vömel; title and journal from memory, not checked."
|
|
212
|
+
|
|
213
|
+
[references.murphy_koop2005]
|
|
214
|
+
authors = "Murphy, D. M.; Koop, T."
|
|
215
|
+
year = 2005
|
|
216
|
+
title = "Review of the vapour pressures of ice and supercooled water for atmospheric applications"
|
|
217
|
+
container = "Quarterly Journal of the Royal Meteorological Society 131, 1539-1565"
|
|
218
|
+
status = "to-confirm"
|
|
219
|
+
note = "Cited by Vömel; journal details from memory, not checked."
|
|
220
|
+
|
|
157
221
|
[references.wmo2024]
|
|
158
222
|
authors = "World Meteorological Organization"
|
|
159
223
|
year = 2024
|
|
@@ -201,6 +265,30 @@ matches = [
|
|
|
201
265
|
{ target = "http://www.wikidata.org/entity/Q94372268", relation = "broad", verified = 2026-10-14 },
|
|
202
266
|
]
|
|
203
267
|
|
|
268
|
+
[constants.epsilon_ashrae2017]
|
|
269
|
+
identifier = "epsilon"
|
|
270
|
+
value = 0.621945
|
|
271
|
+
unit = "UNITLESS"
|
|
272
|
+
description = "Ratio of the molar masses of water and dry air, M_w / M_d"
|
|
273
|
+
reference = "ashrae2017"
|
|
274
|
+
|
|
275
|
+
# Other values of the same ratio, read in their sources. The relative
|
|
276
|
+
# spread (about 6e-5) is negligible against the deviations between
|
|
277
|
+
# formulations, but the value used is part of a formulation.
|
|
278
|
+
[constants.epsilon_ecmwf_ifs]
|
|
279
|
+
identifier = "epsilon"
|
|
280
|
+
value = 0.6219808
|
|
281
|
+
unit = "UNITLESS"
|
|
282
|
+
description = "Ratio of the molar masses of water and dry air, R_d / R_v of the ECMWF IFS"
|
|
283
|
+
reference = "ecmwf_ifs_cy49r1"
|
|
284
|
+
|
|
285
|
+
[constants.epsilon_metpy]
|
|
286
|
+
identifier = "epsilon"
|
|
287
|
+
value = 0.6219569
|
|
288
|
+
unit = "UNITLESS"
|
|
289
|
+
description = "Ratio of the molar masses of water and dry air, M_w / M_d of MetPy"
|
|
290
|
+
reference = "metpy171"
|
|
291
|
+
|
|
204
292
|
# --- Notations ------------------------------------------------------------
|
|
205
293
|
|
|
206
294
|
[notations.wmo]
|
|
@@ -532,3 +620,188 @@ b5 = 0.42873e-3
|
|
|
532
620
|
b6 = 4.76955
|
|
533
621
|
b7 = 0.78614
|
|
534
622
|
T_1 = { value = 273.16, unit = "K", note = "Triple point of water" }
|
|
623
|
+
|
|
624
|
+
[formulations.vapour_pressure_from_relative_humidity]
|
|
625
|
+
label = "Vapour pressure from relative humidity"
|
|
626
|
+
expression = "RH * e_s"
|
|
627
|
+
fixed_units = false
|
|
628
|
+
note = "Relative humidity taken as e/e_s, its usual approximation in meteorology; definitions through mole fractions or mixing ratios differ slightly (source to read before stating which one a standard uses)."
|
|
629
|
+
output = { identifier = "e", kind = "VapourPressure", unit = "PA", description = "Vapour pressure" }
|
|
630
|
+
inputs = [
|
|
631
|
+
{ identifier = "RH", kind = "RelativeHumidity", unit = "UNITLESS", description = "Relative humidity" },
|
|
632
|
+
{ identifier = "e_s", kind = "SaturationVapourPressureOverWater", unit = "PA", description = "Saturation vapour pressure over water at the same temperature" },
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
[formulations.mixing_ratio_from_vapour_pressure]
|
|
636
|
+
label = "Humidity mixing ratio from vapour pressure (ASHRAE 2017)"
|
|
637
|
+
expression = "epsilon * e / (p - e)"
|
|
638
|
+
fixed_units = false
|
|
639
|
+
references = ["ashrae2017", "psychrolib"]
|
|
640
|
+
note = "ASHRAE Handbook - Fundamentals (2017), chapter 1, equation 20, through PsychroLib: moist air as an ideal gas mixture."
|
|
641
|
+
output = { identifier = "r", kind = "MassRatioOfWaterVapourToDryGas", unit = "KiloGM-PER-KiloGM", description = "Humidity mixing ratio" }
|
|
642
|
+
inputs = [
|
|
643
|
+
{ identifier = "e", kind = "VapourPressure", unit = "PA", description = "Vapour pressure" },
|
|
644
|
+
{ identifier = "p", kind = "Pressure", unit = "PA", description = "Air pressure" },
|
|
645
|
+
]
|
|
646
|
+
constants = ["epsilon_ashrae2017"]
|
|
647
|
+
|
|
648
|
+
[formulations.specific_humidity_from_mixing_ratio]
|
|
649
|
+
label = "Specific humidity from mixing ratio"
|
|
650
|
+
expression = "r / (1 + r)"
|
|
651
|
+
fixed_units = false
|
|
652
|
+
references = ["ashrae2017", "psychrolib"]
|
|
653
|
+
note = "From the definitions (mass of water vapour per mass of moist air, per mass of dry air); ASHRAE Handbook - Fundamentals (2017), chapter 1, equation 9b, through PsychroLib."
|
|
654
|
+
output = { identifier = "q", kind = "SpecificHumidity", unit = "KiloGM-PER-KiloGM", description = "Specific humidity" }
|
|
655
|
+
inputs = [
|
|
656
|
+
{ identifier = "r", kind = "MassRatioOfWaterVapourToDryGas", unit = "KiloGM-PER-KiloGM", description = "Humidity mixing ratio" },
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
# --- Saturation vapour pressure over ice -----------------------------------
|
|
660
|
+
|
|
661
|
+
[formulations.iapws_r14_08_sublimation]
|
|
662
|
+
label = "IAPWS R14-08(2011), sublimation pressure of ice Ih"
|
|
663
|
+
expression = "p_t * exp((a1 * (T / T_t) ** b1 + a2 * (T / T_t) ** b2 + a3 * (T / T_t) ** b3) / (T / T_t))"
|
|
664
|
+
fixed_units = true
|
|
665
|
+
references = ["iapws_r14_08", "wagner2011"]
|
|
666
|
+
accuracy = "0.0011 % at the triple point (expanded uncertainty, k = 2); see Fig. 2 of the release below"
|
|
667
|
+
note = "Reference formulation over ice. Reproduces Table 3 of the release: 8.94735 Pa at 230 K; joins the IAPWS equation over water at the triple point."
|
|
668
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "PA", description = "Saturation vapour pressure over ice" }
|
|
669
|
+
inputs = [
|
|
670
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature (ITS-90)", temperature_scale = "its90", scale_basis = "stated" },
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
[formulations.iapws_r14_08_sublimation.coefficients]
|
|
674
|
+
T_t = { value = 273.16, unit = "K", note = "Triple point temperature" }
|
|
675
|
+
p_t = { value = 611.657, unit = "PA", note = "Triple point pressure" }
|
|
676
|
+
a1 = -21.2144006
|
|
677
|
+
a2 = 27.3203819
|
|
678
|
+
a3 = -6.10598130
|
|
679
|
+
b1 = 0.333333333e-2
|
|
680
|
+
b2 = 1.20666667
|
|
681
|
+
b3 = 1.70333333
|
|
682
|
+
|
|
683
|
+
[[formulations.iapws_r14_08_sublimation.domains]]
|
|
684
|
+
basis = "stated"
|
|
685
|
+
bounds = { T = [50.0, 273.16] }
|
|
686
|
+
criterion = "Solid-vapour equilibrium of ice Ih, endorsed by IAPWS (section 5)."
|
|
687
|
+
reference = "iapws_r14_08"
|
|
688
|
+
|
|
689
|
+
[formulations.goff_gratch1946_ice]
|
|
690
|
+
label = "Goff-Gratch equation over ice (Smithsonian Tables form)"
|
|
691
|
+
expression = "10 ** (c1 * (T_0 / T - 1) + c2 * log10(T_0 / T) + c3 * (1 - T / T_0) + log10(e_i0))"
|
|
692
|
+
fixed_units = true
|
|
693
|
+
references = ["goff_gratch1946", "smithsonian1984", "voemel_vp"]
|
|
694
|
+
note = "As quoted by Vömel, who calls it the generally considered reference over ice."
|
|
695
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
696
|
+
inputs = [
|
|
697
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature" },
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
[formulations.goff_gratch1946_ice.coefficients]
|
|
701
|
+
c1 = -9.09718
|
|
702
|
+
c2 = -3.56654
|
|
703
|
+
c3 = 0.876793
|
|
704
|
+
T_0 = { value = 273.16, unit = "K" }
|
|
705
|
+
e_i0 = { value = 6.1071, unit = "HectoPA" }
|
|
706
|
+
|
|
707
|
+
[[formulations.goff_gratch1946_ice.domains]]
|
|
708
|
+
basis = "stated"
|
|
709
|
+
bounds = { T = [173.15, 273.15] }
|
|
710
|
+
criterion = "-100 to 0 degC, range of the equation over ice as stated by Vömel."
|
|
711
|
+
reference = "voemel_vp"
|
|
712
|
+
|
|
713
|
+
[formulations.hyland_wexler1983_ice]
|
|
714
|
+
label = "Hyland and Wexler (1983) formulation over ice"
|
|
715
|
+
expression = "exp(C1 / T + C2 + C3 * T + C4 * T ** 2 + C5 * T ** 3 + C6 * T ** 4 + C7 * ln(T))"
|
|
716
|
+
fixed_units = true
|
|
717
|
+
references = ["hyland_wexler1983", "ashrae2017", "psychrolib", "voemel_vp"]
|
|
718
|
+
note = "ASHRAE Handbook - Fundamentals (2017), chapter 1, equation 5, through PsychroLib; same coefficients in Vömel. Range not read in the original. Temperature scale ITS-90, inferred from numbers: as over water, these coefficients agree with IAPWS R14-08 within 0.032 % from -80 to -1 degC without conversion, and drift to -0.18 % at -80 degC with the IPTS-68 conversion."
|
|
719
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "PA", description = "Saturation vapour pressure over ice" }
|
|
720
|
+
inputs = [
|
|
721
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature", temperature_scale = "its90", scale_basis = "inferred-from-numbers" },
|
|
722
|
+
]
|
|
723
|
+
coefficients = { C1 = -5.6745359e3, C2 = 6.3925247, C3 = -9.677843e-3, C4 = 6.2215701e-7, C5 = 2.0747825e-9, C6 = -9.484024e-13, C7 = 4.1635019 }
|
|
724
|
+
|
|
725
|
+
[formulations.wmo2008_ice]
|
|
726
|
+
label = "Magnus form over ice, WMO Guide (CIMO) coefficients"
|
|
727
|
+
expression = "C * exp(A * t / (B + t))"
|
|
728
|
+
fixed_units = true
|
|
729
|
+
references = ["wmo2008", "voemel_vp"]
|
|
730
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
731
|
+
inputs = [
|
|
732
|
+
{ identifier = "t", kind = "CelsiusTemperature", unit = "DEG_C", description = "Celsius temperature", temperature_scale = "its90", scale_basis = "inferred-from-date" },
|
|
733
|
+
]
|
|
734
|
+
coefficients = { A = 22.46, B = 272.62, C = 6.112 }
|
|
735
|
+
|
|
736
|
+
[formulations.murray1967_ice]
|
|
737
|
+
label = "Magnus-Tetens form over ice, Murray (1967)"
|
|
738
|
+
expression = "C * exp(A * (T - T_0) / (T - B))"
|
|
739
|
+
fixed_units = true
|
|
740
|
+
references = ["murray1967", "voemel_vp"]
|
|
741
|
+
note = "As quoted by Vömel. Departs from the IAPWS equation by about -1.9 % at -40 degC and -11 % at -80 degC; original not read."
|
|
742
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
743
|
+
inputs = [
|
|
744
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature" },
|
|
745
|
+
]
|
|
746
|
+
|
|
747
|
+
[formulations.murray1967_ice.coefficients]
|
|
748
|
+
A = 21.8745584
|
|
749
|
+
B = { value = 7.66, unit = "K" }
|
|
750
|
+
C = { value = 6.1078, unit = "HectoPA" }
|
|
751
|
+
T_0 = { value = 273.16, unit = "K" }
|
|
752
|
+
|
|
753
|
+
[formulations.buck1981_ice]
|
|
754
|
+
label = "Magnus form over ice, Buck (1981) coefficients"
|
|
755
|
+
expression = "C * exp(A * t / (B + t))"
|
|
756
|
+
fixed_units = true
|
|
757
|
+
references = ["buck1981", "voemel_vp"]
|
|
758
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
759
|
+
inputs = [
|
|
760
|
+
{ identifier = "t", kind = "CelsiusTemperature", unit = "DEG_C", description = "Celsius temperature", temperature_scale = "ipts68", scale_basis = "inferred-from-date" },
|
|
761
|
+
]
|
|
762
|
+
coefficients = { A = 22.452, B = 272.55, C = 6.1115 }
|
|
763
|
+
|
|
764
|
+
[formulations.buck1996_ice]
|
|
765
|
+
label = "Buck (1996) equation over ice"
|
|
766
|
+
expression = "C * exp((A - t / D) * t / (B + t))"
|
|
767
|
+
fixed_units = true
|
|
768
|
+
references = ["buck1996", "voemel_vp"]
|
|
769
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
770
|
+
inputs = [
|
|
771
|
+
{ identifier = "t", kind = "CelsiusTemperature", unit = "DEG_C", description = "Celsius temperature", temperature_scale = "its90", scale_basis = "inferred-from-date" },
|
|
772
|
+
]
|
|
773
|
+
coefficients = { A = 23.036, B = 279.82, C = 6.1115, D = 333.7 }
|
|
774
|
+
|
|
775
|
+
[formulations.marti_mauersberger1993]
|
|
776
|
+
label = "Marti and Mauersberger (1993) formulation over ice"
|
|
777
|
+
expression = "10 ** (A / T + B)"
|
|
778
|
+
fixed_units = true
|
|
779
|
+
references = ["marti_mauersberger1993", "voemel_vp"]
|
|
780
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "PA", description = "Saturation vapour pressure over ice" }
|
|
781
|
+
inputs = [
|
|
782
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature", temperature_scale = "its90", scale_basis = "inferred-from-date" },
|
|
783
|
+
]
|
|
784
|
+
coefficients = { A = { value = -2663.5, unit = "K" }, B = 12.537 }
|
|
785
|
+
|
|
786
|
+
[formulations.murphy_koop2005_ice]
|
|
787
|
+
label = "Murphy and Koop (2005) formulation over ice"
|
|
788
|
+
expression = "exp(c0 + c1 / T + c2 * ln(T) + c3 * T)"
|
|
789
|
+
fixed_units = true
|
|
790
|
+
references = ["murphy_koop2005", "voemel_vp"]
|
|
791
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "PA", description = "Saturation vapour pressure over ice" }
|
|
792
|
+
inputs = [
|
|
793
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature", temperature_scale = "its90", scale_basis = "inferred-from-date" },
|
|
794
|
+
]
|
|
795
|
+
coefficients = { c0 = 9.550426, c1 = -5723.265, c2 = 3.53068, c3 = -0.00728332 }
|
|
796
|
+
|
|
797
|
+
[formulations.sonntag1990_ice]
|
|
798
|
+
label = "Sonntag formulation over ice"
|
|
799
|
+
expression = "exp(a0 / T + a1 + a2 * T + a3 * T ** 2 + a4 * ln(T))"
|
|
800
|
+
fixed_units = true
|
|
801
|
+
references = ["sonntag1990", "voemel_vp"]
|
|
802
|
+
note = "As quoted by Vömel, who dates it 1994 (as for the formulation over water)."
|
|
803
|
+
output = { identifier = "e_i", kind = "SaturationVapourPressureOverIce", unit = "HectoPA", description = "Saturation vapour pressure over ice" }
|
|
804
|
+
inputs = [
|
|
805
|
+
{ identifier = "T", kind = "ThermodynamicTemperature", unit = "K", description = "Thermodynamic temperature", temperature_scale = "its90", scale_basis = "inferred-from-date" },
|
|
806
|
+
]
|
|
807
|
+
coefficients = { a0 = -6024.5282, a1 = 24.721994, a2 = 1.0613868e-2, a3 = -1.3198825e-5, a4 = -0.49382577 }
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cf_standard_names": {
|
|
3
|
+
"air_pressure": {
|
|
4
|
+
"canonical_units": "Pa",
|
|
5
|
+
"pint": "Pa"
|
|
6
|
+
},
|
|
3
7
|
"air_temperature": {
|
|
4
8
|
"canonical_units": "K",
|
|
5
9
|
"pint": "K"
|
|
6
10
|
},
|
|
11
|
+
"dew_point_temperature": {
|
|
12
|
+
"canonical_units": "K",
|
|
13
|
+
"pint": "K"
|
|
14
|
+
},
|
|
7
15
|
"humidity_mixing_ratio": {
|
|
8
16
|
"canonical_units": "1",
|
|
9
17
|
"pint": "dimensionless"
|
|
@@ -62,6 +70,23 @@
|
|
|
62
70
|
"scale_type": "interval",
|
|
63
71
|
"specialization_of": "Temperature"
|
|
64
72
|
},
|
|
73
|
+
"DewPointTemperature": {
|
|
74
|
+
"added_units": [],
|
|
75
|
+
"applicable_units": [
|
|
76
|
+
"DEG_C",
|
|
77
|
+
"DEG_F"
|
|
78
|
+
],
|
|
79
|
+
"cf_standard_names": [
|
|
80
|
+
"dew_point_temperature"
|
|
81
|
+
],
|
|
82
|
+
"delta": false,
|
|
83
|
+
"dimension_vector": "A0E0L0I0M0H1T0D0",
|
|
84
|
+
"iri": "http://qudt.org/vocab/quantitykind/DewPointTemperature",
|
|
85
|
+
"label": "Dew Point Temperature",
|
|
86
|
+
"matches": [],
|
|
87
|
+
"scale_type": "interval",
|
|
88
|
+
"specialization_of": "Temperature"
|
|
89
|
+
},
|
|
65
90
|
"MassConcentrationOfWaterVapour": {
|
|
66
91
|
"added_units": [],
|
|
67
92
|
"applicable_units": [
|
|
@@ -112,6 +137,30 @@
|
|
|
112
137
|
"scale_type": "ratio",
|
|
113
138
|
"specialization_of": null
|
|
114
139
|
},
|
|
140
|
+
"Pressure": {
|
|
141
|
+
"added_units": [],
|
|
142
|
+
"applicable_units": [
|
|
143
|
+
"HectoPA",
|
|
144
|
+
"KiloPA",
|
|
145
|
+
"PA"
|
|
146
|
+
],
|
|
147
|
+
"cf_standard_names": [
|
|
148
|
+
"air_pressure"
|
|
149
|
+
],
|
|
150
|
+
"delta": false,
|
|
151
|
+
"dimension_vector": "A0E0L-1I0M1H0T-2D0",
|
|
152
|
+
"iri": "http://qudt.org/vocab/quantitykind/Pressure",
|
|
153
|
+
"label": "Pressure",
|
|
154
|
+
"matches": [
|
|
155
|
+
{
|
|
156
|
+
"relation": "close",
|
|
157
|
+
"target": "http://www.wikidata.org/entity/Q39552",
|
|
158
|
+
"verified": "2026-10-14"
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"scale_type": "ratio",
|
|
162
|
+
"specialization_of": "ForcePerArea"
|
|
163
|
+
},
|
|
115
164
|
"RelativeHumidity": {
|
|
116
165
|
"added_units": [
|
|
117
166
|
"UNITLESS"
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Miki
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""Inverting formulations: closed form by symbolic computation, or numerically.
|
|
5
|
+
|
|
6
|
+
A formulation y = f(x) of one input can be inverted into x = g(y). For
|
|
7
|
+
the dew point, the saturation vapour pressure e_s = f(t) becomes
|
|
8
|
+
t_d = g(e): the temperature at which a vapour pressure e saturates.
|
|
9
|
+
|
|
10
|
+
:func:`invert` first looks for a closed form with SymPy, after taking the
|
|
11
|
+
logarithm of both sides (which turns the usual exponential forms into
|
|
12
|
+
rational equations). The closed form becomes an ordinary
|
|
13
|
+
:class:`~nomogram.model.Formulation`, with an expression in the catalog
|
|
14
|
+
syntax, LaTeX in any notation, validity domains carried through the
|
|
15
|
+
function, and a :class:`~nomogram.model.Derivation` recording its
|
|
16
|
+
origin. When there is none, or when the unknown appears more than
|
|
17
|
+
:data:`SYMBOLIC_MAX_OCCURRENCES` times (to keep the search short), the
|
|
18
|
+
result is a :class:`NumericalInverse`, solved by vectorised bisection
|
|
19
|
+
over a search interval.
|
|
20
|
+
|
|
21
|
+
Temperatures stay on ITS-90 at both ends: an inverse of a formulation
|
|
22
|
+
fitted on another scale converts its result back.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field, replace
|
|
28
|
+
from typing import Any, Literal
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from nomogram.expression import compile_numeric, parse_expression
|
|
33
|
+
from nomogram.model import (
|
|
34
|
+
Basis,
|
|
35
|
+
Coefficient,
|
|
36
|
+
Derivation,
|
|
37
|
+
Formulation,
|
|
38
|
+
OutOfDomainError,
|
|
39
|
+
Outside,
|
|
40
|
+
ScaleConversion,
|
|
41
|
+
ValidityDomain,
|
|
42
|
+
ValidityWarning,
|
|
43
|
+
Variable,
|
|
44
|
+
)
|
|
45
|
+
from nomogram.semantics import semantic_catalog
|
|
46
|
+
from nomogram.units import Q_, convert
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DEW_POINT_SEARCH_CELSIUS",
|
|
50
|
+
"SYMBOLIC_MAX_OCCURRENCES",
|
|
51
|
+
"InversionError",
|
|
52
|
+
"NumericalInverse",
|
|
53
|
+
"dew_point",
|
|
54
|
+
"invert",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
#: Above this many occurrences of the unknown (after taking logarithms),
|
|
58
|
+
#: no closed form is sought: SymPy would search for long and find none.
|
|
59
|
+
SYMBOLIC_MAX_OCCURRENCES = 3
|
|
60
|
+
|
|
61
|
+
#: Interval searched by :func:`dew_point` when the formulation states no
|
|
62
|
+
#: validity domain, in degC: a search interval, not a validity domain.
|
|
63
|
+
DEW_POINT_SEARCH_CELSIUS = (-100.0, 100.0)
|
|
64
|
+
|
|
65
|
+
#: Name of the offset coefficient added when the result changes from K to
|
|
66
|
+
#: degC (t = T - T0, SI Brochure, section 2.3.1).
|
|
67
|
+
_OFFSET = "T_celsius_zero"
|
|
68
|
+
|
|
69
|
+
Method = Literal["auto", "symbolic", "numerical"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InversionError(ValueError):
|
|
73
|
+
"""The formulation cannot be inverted as asked."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _source(expr: Any) -> str:
|
|
77
|
+
"""The catalog-syntax source of a SymPy expression (``ln`` for ``log``)."""
|
|
78
|
+
from sympy.printing.str import StrPrinter
|
|
79
|
+
|
|
80
|
+
class Printer(StrPrinter): # type: ignore[misc]
|
|
81
|
+
def _print_log(self, node: Any) -> str:
|
|
82
|
+
return f"ln({self._print(node.args[0])})"
|
|
83
|
+
|
|
84
|
+
return str(Printer().doprint(expr))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check(
|
|
88
|
+
formulation: Formulation, solve_for: str, output: Variable, input: Variable
|
|
89
|
+
) -> None:
|
|
90
|
+
if len(formulation.inputs) != 1:
|
|
91
|
+
msg = f"{formulation.key}: only formulations of one input can be inverted"
|
|
92
|
+
raise InversionError(msg)
|
|
93
|
+
if formulation.inputs[0].identifier != solve_for:
|
|
94
|
+
msg = f"{formulation.key} has no input {solve_for!r}"
|
|
95
|
+
raise InversionError(msg)
|
|
96
|
+
if input.unit != formulation.output.unit:
|
|
97
|
+
msg = (
|
|
98
|
+
f"the new input must keep the unit of {formulation.key}'s output "
|
|
99
|
+
f"({formulation.output.unit}), the unit its coefficients hold in"
|
|
100
|
+
)
|
|
101
|
+
raise InversionError(msg)
|
|
102
|
+
from nomogram.catalog import catalog
|
|
103
|
+
|
|
104
|
+
kinds = catalog().kinds
|
|
105
|
+
for variable in (output, input):
|
|
106
|
+
kind = kinds.get(variable.kind)
|
|
107
|
+
if kind is None or variable.unit not in kind.applicable_units:
|
|
108
|
+
msg = f"{variable.identifier}: {variable.unit} does not apply to {variable.kind}"
|
|
109
|
+
raise InversionError(msg)
|
|
110
|
+
if (
|
|
111
|
+
formulation.inputs[0].kind == "TemperatureDifference"
|
|
112
|
+
or output.kind == "TemperatureDifference"
|
|
113
|
+
):
|
|
114
|
+
msg = "inverting temperature differences is not supported"
|
|
115
|
+
raise InversionError(msg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _forward(formulation: Formulation) -> Any:
|
|
119
|
+
"""The formulation's numeric function of its single input, in its units."""
|
|
120
|
+
function = compile_numeric(formulation.sympy_expression, formulation.names)
|
|
121
|
+
fixed = [c.value for c in formulation.coefficients] + [
|
|
122
|
+
k.value for k in formulation.constants
|
|
123
|
+
]
|
|
124
|
+
factor = formulation.output_factor
|
|
125
|
+
|
|
126
|
+
def forward(x: Any) -> Any:
|
|
127
|
+
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
|
128
|
+
return np.asarray(function(x, *fixed), dtype=float) * factor
|
|
129
|
+
|
|
130
|
+
return forward
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _bounds(formulation: Formulation, solve_for: str) -> tuple[float, float] | None:
|
|
134
|
+
"""Intersection of the stated domains on the input, if any."""
|
|
135
|
+
stated = [
|
|
136
|
+
d.bounds[solve_for]
|
|
137
|
+
for d in formulation.domains
|
|
138
|
+
if d.basis.value == "stated" and solve_for in d.bounds
|
|
139
|
+
]
|
|
140
|
+
if not stated:
|
|
141
|
+
return None
|
|
142
|
+
return max(low for low, _ in stated), min(high for _, high in stated)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _offset(source_unit: str, target_unit: str) -> float:
|
|
146
|
+
"""What to add to the solved value to express it in the target unit."""
|
|
147
|
+
if source_unit == target_unit:
|
|
148
|
+
return 0.0
|
|
149
|
+
if (source_unit, target_unit) == ("K", "DEG_C"):
|
|
150
|
+
return -273.15
|
|
151
|
+
if (source_unit, target_unit) == ("DEG_C", "K"):
|
|
152
|
+
return 273.15
|
|
153
|
+
msg = f"cannot express a result solved in {source_unit} in {target_unit}"
|
|
154
|
+
raise InversionError(msg)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _symbolic(
|
|
158
|
+
formulation: Formulation,
|
|
159
|
+
solve_for: str,
|
|
160
|
+
output: Variable,
|
|
161
|
+
input: Variable,
|
|
162
|
+
key: str,
|
|
163
|
+
label: str,
|
|
164
|
+
samples: Any,
|
|
165
|
+
) -> Formulation | None:
|
|
166
|
+
import sympy
|
|
167
|
+
|
|
168
|
+
x = sympy.Symbol(solve_for, real=True)
|
|
169
|
+
y = sympy.Symbol(input.identifier, positive=True)
|
|
170
|
+
lhs = sympy.expand_log(sympy.log(formulation.sympy_expression), force=True)
|
|
171
|
+
if lhs.count(x) > SYMBOLIC_MAX_OCCURRENCES:
|
|
172
|
+
return None
|
|
173
|
+
# ln f(x) = k + g(x): solve g(x) = z, then z = ln(y) - k, combined
|
|
174
|
+
# into one logarithm (ln(e/C) for the Magnus form).
|
|
175
|
+
constant, varying = lhs.as_independent(x, as_Add=True)
|
|
176
|
+
z = sympy.Dummy("z", real=True)
|
|
177
|
+
try:
|
|
178
|
+
solutions = sympy.solve(varying - z, x)
|
|
179
|
+
except NotImplementedError:
|
|
180
|
+
return None
|
|
181
|
+
rhs = sympy.logcombine(sympy.log(y) - constant, force=True)
|
|
182
|
+
solutions = [s.subs(z, rhs) for s in solutions]
|
|
183
|
+
shift = _offset(formulation.inputs[0].unit, output.unit)
|
|
184
|
+
coefficients = list(formulation.coefficients)
|
|
185
|
+
if shift:
|
|
186
|
+
coefficients.append(
|
|
187
|
+
Coefficient(_OFFSET, -shift, "K", "t = T - T0 (SI Brochure, section 2.3.1)")
|
|
188
|
+
)
|
|
189
|
+
names = (
|
|
190
|
+
[input.identifier]
|
|
191
|
+
+ [c.identifier for c in coefficients]
|
|
192
|
+
+ [k.identifier for k in formulation.constants]
|
|
193
|
+
)
|
|
194
|
+
forward = _forward(formulation)
|
|
195
|
+
targets = forward(samples)
|
|
196
|
+
for solution in solutions:
|
|
197
|
+
expr = solution
|
|
198
|
+
if shift:
|
|
199
|
+
expr = expr - sympy.Symbol(_OFFSET, real=True)
|
|
200
|
+
source = _source(expr)
|
|
201
|
+
try:
|
|
202
|
+
parsed = parse_expression(source, names)
|
|
203
|
+
except ValueError:
|
|
204
|
+
continue
|
|
205
|
+
values = [c.value for c in coefficients] + [
|
|
206
|
+
k.value for k in formulation.constants
|
|
207
|
+
]
|
|
208
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
209
|
+
back = np.asarray(
|
|
210
|
+
compile_numeric(parsed, tuple(names))(targets, *values), dtype=float
|
|
211
|
+
)
|
|
212
|
+
if np.allclose(back, samples + shift, rtol=1e-9, atol=1e-9):
|
|
213
|
+
return Formulation(
|
|
214
|
+
key=key,
|
|
215
|
+
label=label,
|
|
216
|
+
output=output,
|
|
217
|
+
inputs=(input,),
|
|
218
|
+
coefficients=tuple(coefficients),
|
|
219
|
+
constants=formulation.constants,
|
|
220
|
+
expression=source,
|
|
221
|
+
fixed_units=formulation.fixed_units,
|
|
222
|
+
domains=_carried_domains(
|
|
223
|
+
formulation, solve_for, input.identifier, forward
|
|
224
|
+
),
|
|
225
|
+
accuracy=formulation.accuracy,
|
|
226
|
+
references=formulation.references,
|
|
227
|
+
note=f"Derived by symbolic inversion of {formulation.key}.",
|
|
228
|
+
matches=(),
|
|
229
|
+
derivation=Derivation(formulation.key, "symbolic inversion", solve_for),
|
|
230
|
+
)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _carried_domains(
|
|
235
|
+
formulation: Formulation, solve_for: str, identifier: str, forward: Any
|
|
236
|
+
) -> tuple[ValidityDomain, ...]:
|
|
237
|
+
"""The domains on the input, carried through the function onto its output."""
|
|
238
|
+
carried = []
|
|
239
|
+
for domain in formulation.domains:
|
|
240
|
+
if solve_for not in domain.bounds:
|
|
241
|
+
continue
|
|
242
|
+
low, high = domain.bounds[solve_for]
|
|
243
|
+
ends = sorted(float(v) for v in forward(np.array([low, high])))
|
|
244
|
+
criterion = f"Carried by inversion from {formulation.key}: {domain.criterion or 'no criterion'}"
|
|
245
|
+
carried.append(
|
|
246
|
+
ValidityDomain(
|
|
247
|
+
domain.basis,
|
|
248
|
+
{identifier: (ends[0], ends[1])},
|
|
249
|
+
criterion,
|
|
250
|
+
domain.reference,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return tuple(carried)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class NumericalInverse:
|
|
258
|
+
"""The inverse of a formulation, solved numerically.
|
|
259
|
+
|
|
260
|
+
Evaluable like a formulation (see :func:`nomogram.comparison.compare`
|
|
261
|
+
and :func:`~nomogram.comparison.compose`). The source is assumed
|
|
262
|
+
monotonic over the search interval; values outside its image give
|
|
263
|
+
NaN, and results outside the source's validity domains follow the
|
|
264
|
+
``outside`` option.
|
|
265
|
+
|
|
266
|
+
Attributes
|
|
267
|
+
----------
|
|
268
|
+
key, label : str
|
|
269
|
+
Identification.
|
|
270
|
+
output, input : Variable
|
|
271
|
+
The solved variable and the new input.
|
|
272
|
+
source : Formulation
|
|
273
|
+
The formulation inverted.
|
|
274
|
+
search : tuple of float
|
|
275
|
+
Interval of the source's input searched, in its unit.
|
|
276
|
+
derivation : Derivation
|
|
277
|
+
Origin, with ``method="numerical inversion"``.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
key: str
|
|
281
|
+
label: str
|
|
282
|
+
output: Variable
|
|
283
|
+
input: Variable
|
|
284
|
+
source: Formulation
|
|
285
|
+
search: tuple[float, float]
|
|
286
|
+
derivation: Derivation
|
|
287
|
+
references: tuple[str, ...] = field(default=())
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def inputs(self) -> tuple[Variable, ...]:
|
|
291
|
+
"""The single input, as for a formulation."""
|
|
292
|
+
return (self.input,)
|
|
293
|
+
|
|
294
|
+
def evaluate(
|
|
295
|
+
self,
|
|
296
|
+
*,
|
|
297
|
+
outside: Outside = "nan",
|
|
298
|
+
basis: Basis = "stated",
|
|
299
|
+
scale: ScaleConversion = "convert",
|
|
300
|
+
**inputs: Any,
|
|
301
|
+
) -> Any:
|
|
302
|
+
"""Solve for the output, by vectorised bisection."""
|
|
303
|
+
if set(inputs) != {self.input.identifier}:
|
|
304
|
+
msg = f"{self.key}: expected the input {self.input.identifier}"
|
|
305
|
+
raise TypeError(msg)
|
|
306
|
+
units = semantic_catalog().units
|
|
307
|
+
source_input = self.source.inputs[0]
|
|
308
|
+
target = np.asarray(
|
|
309
|
+
convert(
|
|
310
|
+
inputs[self.input.identifier], units[self.input.unit].pint
|
|
311
|
+
).magnitude,
|
|
312
|
+
dtype=float,
|
|
313
|
+
)
|
|
314
|
+
forward = _forward(self.source)
|
|
315
|
+
low = np.full(target.shape, self.search[0])
|
|
316
|
+
high = np.full(target.shape, self.search[1])
|
|
317
|
+
increasing = forward(np.array(self.search[1])) > forward(
|
|
318
|
+
np.array(self.search[0])
|
|
319
|
+
)
|
|
320
|
+
for _ in range(100):
|
|
321
|
+
middle = (low + high) / 2
|
|
322
|
+
below = forward(middle) < target
|
|
323
|
+
go_up = below if increasing else ~below
|
|
324
|
+
low = np.where(go_up, middle, low)
|
|
325
|
+
high = np.where(go_up, high, middle)
|
|
326
|
+
solved = (low + high) / 2
|
|
327
|
+
ends = forward(np.array(self.search))
|
|
328
|
+
unreachable = (
|
|
329
|
+
(target < ends.min()) | (target > ends.max()) | ~np.isfinite(target)
|
|
330
|
+
)
|
|
331
|
+
solved = np.where(unreachable, np.nan, solved)
|
|
332
|
+
mask = self.source.outside_mask({source_input.identifier: solved}, basis)
|
|
333
|
+
if mask.any() and outside != "ignore":
|
|
334
|
+
message = f"{self.key}: {int(mask.sum())} value(s) outside the {basis} domain of {self.source.key}"
|
|
335
|
+
if outside == "raise":
|
|
336
|
+
raise OutOfDomainError(message)
|
|
337
|
+
if outside == "warn":
|
|
338
|
+
import warnings
|
|
339
|
+
|
|
340
|
+
warnings.warn(message, ValidityWarning, stacklevel=2)
|
|
341
|
+
else:
|
|
342
|
+
solved = np.where(mask, np.nan, solved)
|
|
343
|
+
quantity = Q_(solved, units[source_input.unit].pint)
|
|
344
|
+
scale_of_source = source_input.temperature_scale
|
|
345
|
+
if scale == "convert" and scale_of_source is not None and scale_of_source.t90:
|
|
346
|
+
celsius = scale_of_source.to_its90(convert(quantity, "degC").magnitude)
|
|
347
|
+
quantity = Q_(celsius, "degC")
|
|
348
|
+
result = np.asarray(
|
|
349
|
+
convert(quantity, units[self.output.unit].pint).magnitude, dtype=float
|
|
350
|
+
)
|
|
351
|
+
magnitude = result.item() if result.ndim == 0 else result
|
|
352
|
+
return Q_(magnitude, units[self.output.unit].pint)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def invert(
|
|
356
|
+
formulation: Formulation,
|
|
357
|
+
solve_for: str,
|
|
358
|
+
*,
|
|
359
|
+
output: Variable,
|
|
360
|
+
input: Variable,
|
|
361
|
+
key: str | None = None,
|
|
362
|
+
label: str | None = None,
|
|
363
|
+
method: Method = "auto",
|
|
364
|
+
search: tuple[float, float] | None = None,
|
|
365
|
+
) -> Formulation | NumericalInverse:
|
|
366
|
+
"""Invert a formulation of one input.
|
|
367
|
+
|
|
368
|
+
Parameters
|
|
369
|
+
----------
|
|
370
|
+
formulation : Formulation
|
|
371
|
+
The formulation y = f(x).
|
|
372
|
+
solve_for : str
|
|
373
|
+
Identifier of its input x, which becomes the output.
|
|
374
|
+
output : Variable
|
|
375
|
+
The new output (x's identifier, kind and unit; K and degC are
|
|
376
|
+
interchangeable for temperatures).
|
|
377
|
+
input : Variable
|
|
378
|
+
The new input, in the unit of the formulation's output.
|
|
379
|
+
key, label : str, optional
|
|
380
|
+
Identification of the result; derived from the source otherwise.
|
|
381
|
+
method : {"auto", "symbolic", "numerical"}
|
|
382
|
+
``"auto"`` tries a closed form, then falls back to numerical
|
|
383
|
+
inversion; the others force one method.
|
|
384
|
+
search : (float, float), optional
|
|
385
|
+
Interval of x searched by numerical inversion, in x's unit;
|
|
386
|
+
defaults to the source's stated validity domain.
|
|
387
|
+
|
|
388
|
+
Returns
|
|
389
|
+
-------
|
|
390
|
+
Formulation or NumericalInverse
|
|
391
|
+
A formulation when a closed form was found; its ``derivation``
|
|
392
|
+
records the source and the method.
|
|
393
|
+
|
|
394
|
+
Raises
|
|
395
|
+
------
|
|
396
|
+
InversionError
|
|
397
|
+
If the formulation cannot be inverted as asked.
|
|
398
|
+
"""
|
|
399
|
+
_check(formulation, solve_for, output, input)
|
|
400
|
+
source_input = formulation.inputs[0]
|
|
401
|
+
output = replace(
|
|
402
|
+
output,
|
|
403
|
+
temperature_scale=source_input.temperature_scale,
|
|
404
|
+
scale_basis=source_input.scale_basis,
|
|
405
|
+
)
|
|
406
|
+
key = key or f"{formulation.key}_inverse"
|
|
407
|
+
label = label or f"Inverse of: {formulation.label}"
|
|
408
|
+
interval = search or _bounds(formulation, solve_for)
|
|
409
|
+
if method != "numerical":
|
|
410
|
+
if interval is not None:
|
|
411
|
+
samples = np.linspace(interval[0], interval[1], 7)[1:-1]
|
|
412
|
+
else:
|
|
413
|
+
# Ordinary temperatures, to pick the right branch of a solution.
|
|
414
|
+
samples = np.array([5.0, 20.0, 35.0]) + (
|
|
415
|
+
273.15 if source_input.unit == "K" else 0.0
|
|
416
|
+
)
|
|
417
|
+
closed = _symbolic(formulation, solve_for, output, input, key, label, samples)
|
|
418
|
+
if closed is not None:
|
|
419
|
+
return closed
|
|
420
|
+
if method == "symbolic":
|
|
421
|
+
msg = f"{formulation.key}: no closed form found for {solve_for}"
|
|
422
|
+
raise InversionError(msg)
|
|
423
|
+
if interval is None:
|
|
424
|
+
msg = (
|
|
425
|
+
f"{formulation.key} states no validity domain on {solve_for}: "
|
|
426
|
+
"give a search interval for numerical inversion"
|
|
427
|
+
)
|
|
428
|
+
raise InversionError(msg)
|
|
429
|
+
return NumericalInverse(
|
|
430
|
+
key=key,
|
|
431
|
+
label=label,
|
|
432
|
+
output=output,
|
|
433
|
+
input=input,
|
|
434
|
+
source=formulation,
|
|
435
|
+
search=(float(interval[0]), float(interval[1])),
|
|
436
|
+
derivation=Derivation(formulation.key, "numerical inversion", solve_for),
|
|
437
|
+
references=formulation.references,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def dew_point(
|
|
442
|
+
formulation: Formulation,
|
|
443
|
+
*,
|
|
444
|
+
method: Method = "auto",
|
|
445
|
+
search: tuple[float, float] | None = None,
|
|
446
|
+
) -> Formulation | NumericalInverse:
|
|
447
|
+
"""The dew point formulation obtained by inverting a saturation vapour pressure.
|
|
448
|
+
|
|
449
|
+
Parameters
|
|
450
|
+
----------
|
|
451
|
+
formulation : Formulation
|
|
452
|
+
A saturation vapour pressure formulation e_s = f(T).
|
|
453
|
+
method, search
|
|
454
|
+
As for :func:`invert`. Without a stated validity domain, numerical
|
|
455
|
+
inversion searches :data:`DEW_POINT_SEARCH_CELSIUS`.
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
Formulation or NumericalInverse
|
|
460
|
+
t_d = g(e), with e a vapour pressure (in the unit of e_s) and t_d
|
|
461
|
+
a dew point temperature in degC.
|
|
462
|
+
"""
|
|
463
|
+
temperature = formulation.inputs[0]
|
|
464
|
+
if search is None and _bounds(formulation, temperature.identifier) is None:
|
|
465
|
+
# A search interval only, not a validity domain.
|
|
466
|
+
search = DEW_POINT_SEARCH_CELSIUS
|
|
467
|
+
if temperature.unit == "K":
|
|
468
|
+
search = (search[0] + 273.15, search[1] + 273.15)
|
|
469
|
+
return invert(
|
|
470
|
+
formulation,
|
|
471
|
+
temperature.identifier,
|
|
472
|
+
output=Variable("t_d", "DewPointTemperature", "DEG_C", "Dew point temperature"),
|
|
473
|
+
input=Variable(
|
|
474
|
+
"e", "VapourPressure", formulation.output.unit, "Vapour pressure"
|
|
475
|
+
),
|
|
476
|
+
key=f"{formulation.key}_dew_point",
|
|
477
|
+
label=f"Dew point, inverse of: {formulation.label}",
|
|
478
|
+
method=method,
|
|
479
|
+
search=search,
|
|
480
|
+
)
|
|
@@ -43,6 +43,7 @@ from nomogram.units import Q_, convert
|
|
|
43
43
|
|
|
44
44
|
__all__ = [
|
|
45
45
|
"Coefficient",
|
|
46
|
+
"Derivation",
|
|
46
47
|
"DomainBasis",
|
|
47
48
|
"Formulation",
|
|
48
49
|
"Notation",
|
|
@@ -198,6 +199,42 @@ class TemperatureScale:
|
|
|
198
199
|
shift = np.interp(values, self.t90, self.difference)
|
|
199
200
|
return np.where(outside, np.nan, values - shift), outside
|
|
200
201
|
|
|
202
|
+
def to_its90(self, celsius: Any) -> Any:
|
|
203
|
+
"""ITS-90 Celsius temperatures, from temperatures on this scale.
|
|
204
|
+
|
|
205
|
+
Inverts :meth:`from_its90` by fixed-point iteration (the
|
|
206
|
+
differences are millikelvins and vary slowly); NaN beyond the
|
|
207
|
+
table.
|
|
208
|
+
"""
|
|
209
|
+
values = np.asarray(celsius, dtype=float)
|
|
210
|
+
if not self.t90:
|
|
211
|
+
return values
|
|
212
|
+
t90 = values
|
|
213
|
+
for _ in range(4):
|
|
214
|
+
t90 = values + np.interp(t90, self.t90, self.difference)
|
|
215
|
+
outside = (t90 < self.t90[0]) | (t90 > self.t90[-1])
|
|
216
|
+
return np.where(outside, np.nan, t90)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class Derivation:
|
|
221
|
+
"""How a formulation was derived from another one.
|
|
222
|
+
|
|
223
|
+
Attributes
|
|
224
|
+
----------
|
|
225
|
+
source : str
|
|
226
|
+
Key of the formulation it derives from.
|
|
227
|
+
method : str
|
|
228
|
+
``"symbolic inversion"`` (closed form found by SymPy) or
|
|
229
|
+
``"numerical inversion"``.
|
|
230
|
+
solved_for : str
|
|
231
|
+
Identifier of the source's input that became the output.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
source: str
|
|
235
|
+
method: str
|
|
236
|
+
solved_for: str
|
|
237
|
+
|
|
201
238
|
|
|
202
239
|
@dataclass(frozen=True)
|
|
203
240
|
class Variable:
|
|
@@ -411,6 +448,9 @@ class Formulation:
|
|
|
411
448
|
Remark.
|
|
412
449
|
matches : tuple of ExternalMatch
|
|
413
450
|
Links to external vocabularies (Wikidata item of the formula...).
|
|
451
|
+
derivation : Derivation or None
|
|
452
|
+
For a formulation derived from another one (see
|
|
453
|
+
:mod:`nomogram.inversion`), how it was derived.
|
|
414
454
|
"""
|
|
415
455
|
|
|
416
456
|
key: str
|
|
@@ -426,6 +466,7 @@ class Formulation:
|
|
|
426
466
|
references: tuple[str, ...]
|
|
427
467
|
note: str | None
|
|
428
468
|
matches: tuple[ExternalMatch, ...]
|
|
469
|
+
derivation: Derivation | None = None
|
|
429
470
|
|
|
430
471
|
@property
|
|
431
472
|
def names(self) -> tuple[str, ...]:
|
|
@@ -461,11 +502,14 @@ class Formulation:
|
|
|
461
502
|
if self.fixed_units:
|
|
462
503
|
return 1.0
|
|
463
504
|
units = semantic_catalog().units
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
505
|
+
# Distinct probe values, so that no difference such as p - e
|
|
506
|
+
# vanishes; only the unit of the result matters.
|
|
507
|
+
probe = iter(1.5 ** (i + 1) for i in range(len(self.names)))
|
|
508
|
+
values: list[Any] = [Q_(next(probe), units[v.unit].pint) for v in self.inputs]
|
|
509
|
+
values += [next(probe) for _ in self.coefficients]
|
|
510
|
+
values += [Q_(next(probe), units[k.unit].pint) for k in self.constants]
|
|
511
|
+
result = Q_(self._function(*values))
|
|
512
|
+
return float(Q_(1.0, result.units).to(units[self.output.unit].pint).magnitude)
|
|
469
513
|
|
|
470
514
|
def latex(self, notation: Notation | None = None) -> str:
|
|
471
515
|
"""The formulation as a LaTeX equation, in a given notation."""
|
|
@@ -590,5 +634,13 @@ class Formulation:
|
|
|
590
634
|
warnings.warn(message, ValidityWarning, stacklevel=2)
|
|
591
635
|
else:
|
|
592
636
|
result = np.where(mask, np.nan, result)
|
|
637
|
+
output_unit = units[self.output.unit].pint
|
|
638
|
+
output_scale = self.output.temperature_scale
|
|
639
|
+
if scale == "convert" and output_scale is not None and output_scale.t90:
|
|
640
|
+
# A temperature computed on the formulation's scale goes back to
|
|
641
|
+
# ITS-90, the scale of inputs and results.
|
|
642
|
+
celsius = convert(Q_(result, output_unit), "degC").magnitude
|
|
643
|
+
its90 = Q_(output_scale.to_its90(celsius), "degC")
|
|
644
|
+
result = np.asarray(convert(its90, output_unit).magnitude, dtype=float)
|
|
593
645
|
magnitude = result.item() if result.ndim == 0 else result
|
|
594
|
-
return Q_(magnitude,
|
|
646
|
+
return Q_(magnitude, output_unit)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|