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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nomogram
3
- Version: 0.1.1
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
  [![codecov](https://codecov.io/gh/miki4iaml/nomogram/graph/badge.svg?branch=develop)](https://codecov.io/gh/miki4iaml/nomogram)
42
42
  [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](pyproject.toml)
43
43
  [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](LICENSE)
44
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23373134.svg)](https://doi.org/10.5281/zenodo.23373134)
44
45
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
45
46
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
46
47
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](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
  [![codecov](https://codecov.io/gh/miki4iaml/nomogram/graph/badge.svg?branch=develop)](https://codecov.io/gh/miki4iaml/nomogram)
8
8
  [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](pyproject.toml)
9
9
  [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](LICENSE)
10
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23373134.svg)](https://doi.org/10.5281/zenodo.23373134)
10
11
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
11
12
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
12
13
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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), and
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({"ThermodynamicTemperature", "CelsiusTemperature"})
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 != target.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
- 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)
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, units[self.output.unit].pint)
646
+ return Q_(magnitude, output_unit)
File without changes
File without changes
File without changes
File without changes