nomogram 0.4.0__tar.gz → 0.6.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.
Files changed (31) hide show
  1. {nomogram-0.4.0 → nomogram-0.6.0}/PKG-INFO +50 -21
  2. {nomogram-0.4.0 → nomogram-0.6.0}/README.md +48 -19
  3. {nomogram-0.4.0 → nomogram-0.6.0}/pyproject.toml +19 -3
  4. {nomogram-0.4.0 → nomogram-0.6.0}/pyproject.toml.orig +13 -4
  5. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/catalog.py +151 -42
  6. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/comparison.py +198 -11
  7. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/NOTICE.md +1 -1
  8. nomogram-0.6.0/src/nomogram/data/catalog/atmosphere.toml +2128 -0
  9. nomogram-0.6.0/src/nomogram/data/catalog/boundary-layer.toml +850 -0
  10. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/core.toml +40 -2
  11. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/moist-air.toml +713 -12
  12. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/radio.toml +1 -22
  13. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/verifications.json +20 -0
  14. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/water-vapour.toml +288 -14
  15. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/semantics.json +610 -75
  16. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/dimensions.py +31 -3
  17. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/display.py +17 -2
  18. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/expression.py +144 -16
  19. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/inversion.py +24 -12
  20. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/model.py +20 -5
  21. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/plot.py +466 -52
  22. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/relations.py +192 -37
  23. {nomogram-0.4.0 → nomogram-0.6.0}/LICENSE +0 -0
  24. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/__init__.py +0 -0
  25. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/_logo.py +0 -0
  26. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/humidity.toml +0 -0
  27. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/data/catalog/sea-water.toml +0 -0
  28. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/py.typed +0 -0
  29. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/search.py +0 -0
  30. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/semantics.py +0 -0
  31. {nomogram-0.4.0 → nomogram-0.6.0}/src/nomogram/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nomogram
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Physics
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Dist: numpy>=2.0
24
24
  Requires-Dist: pint>=0.24.4
25
- Requires-Dist: sympy>=1.13
25
+ Requires-Dist: sympy>=1.14
26
26
  Requires-Dist: matplotlib>=3.10.7 ; extra == 'viz'
27
27
  Requires-Python: >=3.12
28
28
  Project-URL: Homepage, https://github.com/miki4iaml/nomogram
@@ -34,18 +34,18 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  <p align="center">
36
36
  <picture>
37
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/banner-dark.svg">
38
- <img alt="nomogram" src="docs/images/banner-light.svg" width="480">
37
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/banner-dark.svg">
38
+ <img alt="nomogram" src="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/banner-light.svg" width="480">
39
39
  </picture>
40
40
  </p>
41
41
 
42
- **English** | [Français](README.fr.md)
42
+ **English** | [Français](https://github.com/miki4iaml/nomogram/blob/v0.6.0/README.fr.md)
43
43
 
44
44
  [![CI](https://github.com/miki4iaml/nomogram/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/miki4iaml/nomogram/actions/workflows/ci.yml)
45
45
  [![CodeQL](https://github.com/miki4iaml/nomogram/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/miki4iaml/nomogram/actions/workflows/github-code-scanning/codeql)
46
46
  [![codecov](https://codecov.io/gh/miki4iaml/nomogram/graph/badge.svg?branch=develop)](https://codecov.io/gh/miki4iaml/nomogram)
47
- [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](pyproject.toml)
48
- [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](LICENSE)
47
+ [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](https://github.com/miki4iaml/nomogram/blob/v0.6.0/pyproject.toml)
48
+ [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](https://github.com/miki4iaml/nomogram/blob/v0.6.0/LICENSE)
49
49
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23373134.svg)](https://doi.org/10.5281/zenodo.23373134)
50
50
  [![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)
51
51
  [![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)
@@ -94,7 +94,7 @@ Python 3.12 or later. Plots need the `viz` extra (matplotlib):
94
94
  Every saturation vapour pressure formulation of the catalog against the
95
95
  IAPWS reference, from 0 to 60 °C and within 1 %; curves stop where a formulation leaves its
96
96
  stated validity domain, and the figure takes the option set of nomogram's
97
- documentation ([script](docs/examples/saturation_vapour_pressure.py)):
97
+ documentation ([script](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/examples/saturation_vapour_pressure.py)):
98
98
 
99
99
  ```python
100
100
  import numpy as np
@@ -113,7 +113,7 @@ with nomo.display.display_options(nomo.display.DisplayOptions.DOCUMENTATION):
113
113
  nomo.plot.plot_deviations(table, xlim=(0, 60), ylim=(-1.0, 1.0)) # extra viz
114
114
  ```
115
115
 
116
- ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.4.0/docs/images/saturation-vapour-pressure.png)
116
+ ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/saturation-vapour-pressure.png)
117
117
 
118
118
  ## Usage
119
119
 
@@ -129,8 +129,29 @@ ha = nomo.comparison.compose(
129
129
  ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
130
130
  ```
131
131
 
132
+ `compare` gives each value to the input of its quantity kind. A
133
+ formulation with several inputs of one kind, such as a scheme
134
+ interpolating between two levels (the target pressure p and the
135
+ pressures p_1 and p_2 of the levels), compares once all but one of them
136
+ are fixed, which `compare` asks for: `partial` fixes inputs as `functools.partial` fixes arguments,
137
+ here the two levels, and leaves a function of p alone, evaluated,
138
+ compared and plotted like any formulation
139
+ ([Humidity between two levels](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/vertical.md#humidity-between-two-levels)).
140
+ It also fixes what is known in advance, such as the levels of a sounding,
141
+ and names the curve in legends:
142
+
143
+ ```python
144
+ levels = {"p_1": Q_(850, "hPa"), "p_2": Q_(500, "hPa"),
145
+ "T_1": Q_(278, "K"), "T_2": Q_(252, "K")}
146
+ in_ln_p = nomo.comparison.partial(
147
+ catalog.formulations["log_pressure_interpolation_temperature"],
148
+ label="T linear in ln p", **levels,
149
+ )
150
+ in_ln_p.evaluate(p=Q_(700, "hPa")).to("K") # 268.49
151
+ ```
152
+
132
153
  Formulations are found by words, wildcards and fields, whatever their
133
- notation ([Finding formulations](docs/search.md)):
154
+ notation ([Finding formulations](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/search.md)):
134
155
 
135
156
  ```python
136
157
  catalog.search(variable="rho*") # ρ in every notation
@@ -138,20 +159,28 @@ catalog.search(kind="VapourPressure", source="buck*")
138
159
  ```
139
160
 
140
161
  Each formulation keeps its units, validity domain, temperature scale and
141
- references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md);
142
- [How nomogram earns trust](docs/approach.md) explains what is checked and
143
- how, and [Displaying formulas](docs/display.md) shows formulas in notebooks,
144
- Markdown and plots. [Sea water](docs/sea-water.md) shows the salinity
162
+ references. The vocabulary is mapped in [docs/rosetta.md](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/rosetta.md),
163
+ and [Shared vocabularies](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/shared-vocabularies.md) explains how nomogram
164
+ builds on QUDT, CF and Wikidata and contributes to them;
165
+ [How nomogram earns trust](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/approach.md) explains what is checked and
166
+ how, and [Displaying formulas](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/display.md) shows formulas in notebooks,
167
+ Markdown and plots. [Sea water](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/sea-water.md) shows the salinity
145
168
  scales and the vapour pressure of sea water, and what its 2 % lowering
146
- changes at the sea surface. [The logo](docs/logo.md) is a working
147
- nomogram.
169
+ changes at the sea surface. [Humidity aloft](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/humidity-aloft.md)
170
+ sets the formula of a radiosonde's producer against its users' along a
171
+ vertical profile, through the dew point, over water or over ice, and
172
+ against WMO's requirements.
173
+ [Security and compliance](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/security-and-compliance.md) gathers, for
174
+ those who approve its use on a laboratory network, how nomogram is
175
+ developed, checked and published, what remains to do, and a checklist.
176
+ [The logo](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/logo.md) is a working nomogram.
148
177
 
149
178
  ## Contributing
150
179
 
151
180
  References to integrate, information or corrections about catalog
152
181
  entries, new formulas and code are all welcome: see the
153
- [ways to contribute](.github/CONTRIBUTING.md#ways-to-contribute) and
154
- the [catalog guide](docs/catalog.md).
182
+ [ways to contribute](https://github.com/miki4iaml/nomogram/blob/v0.6.0/.github/CONTRIBUTING.md#ways-to-contribute) and
183
+ the [catalog guide](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/catalog.md).
155
184
 
156
185
  ## Data sources
157
186
 
@@ -162,7 +191,7 @@ their canonical units, from the CF standard name table. The package
162
191
  bundles a subset of each source:
163
192
 
164
193
  - QUDT — Quantities, Units, Dimensions and Types, by QUDT.org
165
- (<https://qudt.org>), release 3.5.2, licensed under
194
+ (<https://qudt.org>), release 3.5.3, licensed under
166
195
  [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/);
167
196
  - the [CF standard name table](https://cfconventions.org/Data/cf-standard-names/95/build/cf-standard-name-table.html),
168
197
  version 95, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/);
@@ -170,9 +199,9 @@ bundles a subset of each source:
170
199
  Institute, Inc., [UCUM License](https://ucum.org/license).
171
200
 
172
201
  What nomogram selects, changes and adds is listed in
173
- [NOTICE.md](src/nomogram/data/NOTICE.md).
202
+ [NOTICE.md](https://github.com/miki4iaml/nomogram/blob/v0.6.0/src/nomogram/data/NOTICE.md).
174
203
 
175
204
  ## License
176
205
 
177
- Code: [BSD-3-Clause](LICENSE). Bundled data: see
206
+ Code: [BSD-3-Clause](https://github.com/miki4iaml/nomogram/blob/v0.6.0/LICENSE). Bundled data: see
178
207
  [Data sources](#data-sources).
@@ -1,17 +1,17 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/banner-dark.svg">
4
- <img alt="nomogram" src="docs/images/banner-light.svg" width="480">
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/banner-dark.svg">
4
+ <img alt="nomogram" src="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/banner-light.svg" width="480">
5
5
  </picture>
6
6
  </p>
7
7
 
8
- **English** | [Français](README.fr.md)
8
+ **English** | [Français](https://github.com/miki4iaml/nomogram/blob/v0.6.0/README.fr.md)
9
9
 
10
10
  [![CI](https://github.com/miki4iaml/nomogram/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/miki4iaml/nomogram/actions/workflows/ci.yml)
11
11
  [![CodeQL](https://github.com/miki4iaml/nomogram/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/miki4iaml/nomogram/actions/workflows/github-code-scanning/codeql)
12
12
  [![codecov](https://codecov.io/gh/miki4iaml/nomogram/graph/badge.svg?branch=develop)](https://codecov.io/gh/miki4iaml/nomogram)
13
- [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](pyproject.toml)
14
- [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](LICENSE)
13
+ [![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmiki4iaml%2Fnomogram%2Fdevelop%2Fpyproject.toml)](https://github.com/miki4iaml/nomogram/blob/v0.6.0/pyproject.toml)
14
+ [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](https://github.com/miki4iaml/nomogram/blob/v0.6.0/LICENSE)
15
15
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23373134.svg)](https://doi.org/10.5281/zenodo.23373134)
16
16
  [![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)
17
17
  [![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)
@@ -60,7 +60,7 @@ Python 3.12 or later. Plots need the `viz` extra (matplotlib):
60
60
  Every saturation vapour pressure formulation of the catalog against the
61
61
  IAPWS reference, from 0 to 60 °C and within 1 %; curves stop where a formulation leaves its
62
62
  stated validity domain, and the figure takes the option set of nomogram's
63
- documentation ([script](docs/examples/saturation_vapour_pressure.py)):
63
+ documentation ([script](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/examples/saturation_vapour_pressure.py)):
64
64
 
65
65
  ```python
66
66
  import numpy as np
@@ -79,7 +79,7 @@ with nomo.display.display_options(nomo.display.DisplayOptions.DOCUMENTATION):
79
79
  nomo.plot.plot_deviations(table, xlim=(0, 60), ylim=(-1.0, 1.0)) # extra viz
80
80
  ```
81
81
 
82
- ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.4.0/docs/images/saturation-vapour-pressure.png)
82
+ ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.6.0/docs/images/saturation-vapour-pressure.png)
83
83
 
84
84
  ## Usage
85
85
 
@@ -95,8 +95,29 @@ ha = nomo.comparison.compose(
95
95
  ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
96
96
  ```
97
97
 
98
+ `compare` gives each value to the input of its quantity kind. A
99
+ formulation with several inputs of one kind, such as a scheme
100
+ interpolating between two levels (the target pressure p and the
101
+ pressures p_1 and p_2 of the levels), compares once all but one of them
102
+ are fixed, which `compare` asks for: `partial` fixes inputs as `functools.partial` fixes arguments,
103
+ here the two levels, and leaves a function of p alone, evaluated,
104
+ compared and plotted like any formulation
105
+ ([Humidity between two levels](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/vertical.md#humidity-between-two-levels)).
106
+ It also fixes what is known in advance, such as the levels of a sounding,
107
+ and names the curve in legends:
108
+
109
+ ```python
110
+ levels = {"p_1": Q_(850, "hPa"), "p_2": Q_(500, "hPa"),
111
+ "T_1": Q_(278, "K"), "T_2": Q_(252, "K")}
112
+ in_ln_p = nomo.comparison.partial(
113
+ catalog.formulations["log_pressure_interpolation_temperature"],
114
+ label="T linear in ln p", **levels,
115
+ )
116
+ in_ln_p.evaluate(p=Q_(700, "hPa")).to("K") # 268.49
117
+ ```
118
+
98
119
  Formulations are found by words, wildcards and fields, whatever their
99
- notation ([Finding formulations](docs/search.md)):
120
+ notation ([Finding formulations](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/search.md)):
100
121
 
101
122
  ```python
102
123
  catalog.search(variable="rho*") # ρ in every notation
@@ -104,20 +125,28 @@ catalog.search(kind="VapourPressure", source="buck*")
104
125
  ```
105
126
 
106
127
  Each formulation keeps its units, validity domain, temperature scale and
107
- references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md);
108
- [How nomogram earns trust](docs/approach.md) explains what is checked and
109
- how, and [Displaying formulas](docs/display.md) shows formulas in notebooks,
110
- Markdown and plots. [Sea water](docs/sea-water.md) shows the salinity
128
+ references. The vocabulary is mapped in [docs/rosetta.md](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/rosetta.md),
129
+ and [Shared vocabularies](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/shared-vocabularies.md) explains how nomogram
130
+ builds on QUDT, CF and Wikidata and contributes to them;
131
+ [How nomogram earns trust](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/approach.md) explains what is checked and
132
+ how, and [Displaying formulas](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/display.md) shows formulas in notebooks,
133
+ Markdown and plots. [Sea water](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/sea-water.md) shows the salinity
111
134
  scales and the vapour pressure of sea water, and what its 2 % lowering
112
- changes at the sea surface. [The logo](docs/logo.md) is a working
113
- nomogram.
135
+ changes at the sea surface. [Humidity aloft](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/humidity-aloft.md)
136
+ sets the formula of a radiosonde's producer against its users' along a
137
+ vertical profile, through the dew point, over water or over ice, and
138
+ against WMO's requirements.
139
+ [Security and compliance](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/security-and-compliance.md) gathers, for
140
+ those who approve its use on a laboratory network, how nomogram is
141
+ developed, checked and published, what remains to do, and a checklist.
142
+ [The logo](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/logo.md) is a working nomogram.
114
143
 
115
144
  ## Contributing
116
145
 
117
146
  References to integrate, information or corrections about catalog
118
147
  entries, new formulas and code are all welcome: see the
119
- [ways to contribute](.github/CONTRIBUTING.md#ways-to-contribute) and
120
- the [catalog guide](docs/catalog.md).
148
+ [ways to contribute](https://github.com/miki4iaml/nomogram/blob/v0.6.0/.github/CONTRIBUTING.md#ways-to-contribute) and
149
+ the [catalog guide](https://github.com/miki4iaml/nomogram/blob/v0.6.0/docs/catalog.md).
121
150
 
122
151
  ## Data sources
123
152
 
@@ -128,7 +157,7 @@ their canonical units, from the CF standard name table. The package
128
157
  bundles a subset of each source:
129
158
 
130
159
  - QUDT — Quantities, Units, Dimensions and Types, by QUDT.org
131
- (<https://qudt.org>), release 3.5.2, licensed under
160
+ (<https://qudt.org>), release 3.5.3, licensed under
132
161
  [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/);
133
162
  - the [CF standard name table](https://cfconventions.org/Data/cf-standard-names/95/build/cf-standard-name-table.html),
134
163
  version 95, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/);
@@ -136,9 +165,9 @@ bundles a subset of each source:
136
165
  Institute, Inc., [UCUM License](https://ucum.org/license).
137
166
 
138
167
  What nomogram selects, changes and adds is listed in
139
- [NOTICE.md](src/nomogram/data/NOTICE.md).
168
+ [NOTICE.md](https://github.com/miki4iaml/nomogram/blob/v0.6.0/src/nomogram/data/NOTICE.md).
140
169
 
141
170
  ## License
142
171
 
143
- Code: [BSD-3-Clause](LICENSE). Bundled data: see
172
+ Code: [BSD-3-Clause](https://github.com/miki4iaml/nomogram/blob/v0.6.0/LICENSE). Bundled data: see
144
173
  [Data sources](#data-sources).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -39,7 +39,7 @@ classifiers = [
39
39
  dependencies = [
40
40
  "numpy>=2.0",
41
41
  "pint>=0.24.4",
42
- "sympy>=1.13",
42
+ "sympy>=1.14",
43
43
  ]
44
44
 
45
45
  [[project.authors]]
@@ -56,7 +56,7 @@ Changelog = "https://github.com/miki4iaml/nomogram/blob/main/CHANGELOG.md"
56
56
  viz = ["matplotlib>=3.10.7"]
57
57
 
58
58
  [build-system]
59
- requires = ["uv_build>=0.12.18,<0.13.0"]
59
+ requires = ["uv_build>=0.12.18,<0.14.0"]
60
60
  build-backend = "uv_build"
61
61
 
62
62
  [dependency-groups]
@@ -133,8 +133,24 @@ extend-select = [
133
133
  "PTH",
134
134
  "RET",
135
135
  "T20",
136
+ "TID251",
136
137
  ]
137
138
 
139
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."sympy.sympify"]
140
+ msg = "read formulas with nomogram.expression.parse_expression"
141
+
142
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."sympy.core.sympify"]
143
+ msg = "read formulas with nomogram.expression.parse_expression"
144
+
145
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."sympy.parse_expr"]
146
+ msg = "read formulas with nomogram.expression.parse_expression"
147
+
148
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."sympy.parsing"]
149
+ msg = "read formulas with nomogram.expression.parse_expression"
150
+
151
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."sympy.parsing.sympy_parser"]
152
+ msg = "read formulas with nomogram.expression.parse_expression"
153
+
138
154
  [tool.ruff.lint.per-file-ignores]
139
155
  "tests/**" = ["ARG001"]
140
156
  "scripts/**" = ["T20"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -39,7 +39,7 @@ classifiers = [
39
39
  dependencies = [
40
40
  "numpy>=2.0",
41
41
  "pint>=0.24.4",
42
- "sympy>=1.13",
42
+ "sympy>=1.14",
43
43
  ]
44
44
 
45
45
  [project.urls]
@@ -54,7 +54,7 @@ viz = [
54
54
  ]
55
55
 
56
56
  [build-system]
57
- requires = ["uv_build>=0.12.18,<0.13.0"]
57
+ requires = ["uv_build>=0.12.18,<0.14.0"]
58
58
  build-backend = "uv_build"
59
59
 
60
60
  [dependency-groups]
@@ -110,7 +110,16 @@ exclude = ["*.md"]
110
110
 
111
111
  [tool.ruff.lint]
112
112
  # On top of ruff's default selection, which Dependabot bumps surface in CI.
113
- extend-select = ["ARG", "B", "C4", "PIE", "PT", "PTH", "RET", "T20"]
113
+ extend-select = ["ARG", "B", "C4", "PIE", "PT", "PTH", "RET", "T20", "TID251"]
114
+
115
+ # A formula is read by nomogram.expression.parse_expression, against a
116
+ # whitelist: never by SymPy's parsers, which rely on eval (AGENTS.md).
117
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
118
+ "sympy.sympify".msg = "read formulas with nomogram.expression.parse_expression"
119
+ "sympy.core.sympify".msg = "read formulas with nomogram.expression.parse_expression"
120
+ "sympy.parse_expr".msg = "read formulas with nomogram.expression.parse_expression"
121
+ "sympy.parsing".msg = "read formulas with nomogram.expression.parse_expression"
122
+ "sympy.parsing.sympy_parser".msg = "read formulas with nomogram.expression.parse_expression"
114
123
 
115
124
  [tool.ruff.lint.per-file-ignores]
116
125
  # Fixtures requested for their side effects look like unused arguments.
@@ -87,6 +87,9 @@ from nomogram.model import (
87
87
  _origin,
88
88
  )
89
89
  from nomogram.relations import (
90
+ _alike,
91
+ _head,
92
+ _shape,
90
93
  _unit_factor,
91
94
  match_inputs,
92
95
  record,
@@ -292,8 +295,8 @@ class Catalog:
292
295
  >>> c = catalog()
293
296
  >>> "formulations.buck1981_magnus" in c.search("buck*", section="formulations")
294
297
  True
295
- >>> c.search(variable="rho*").refs[:2]
296
- ('formulations.mass_concentration_ideal_gas', 'formulations.itu_r_p453_vapour_pressure_from_density')
298
+ >>> "formulations.mass_concentration_ideal_gas" in c.search(variable="rho*")
299
+ True
297
300
  """
298
301
  from nomogram.search import search
299
302
 
@@ -1014,7 +1017,7 @@ class _Builder:
1014
1017
  names = {
1015
1018
  self.constants[n].identifier: n for n in self.constants[key].components
1016
1019
  }
1017
- return expr.subs({s: value(names[s.name]) for s in expr.free_symbols})
1020
+ return expr.xreplace({s: value(names[s.name]) for s in expr.free_symbols})
1018
1021
 
1019
1022
  def unrounded(self, key: str) -> Any:
1020
1023
  """The exact value of a constant: its definition, recursively, else as printed."""
@@ -1713,6 +1716,70 @@ class _Builder:
1713
1716
  raise CatalogError(msg)
1714
1717
  return formulations[name]
1715
1718
 
1719
+ @staticmethod
1720
+ def bindings(
1721
+ place: str, table: Any, inner: Mapping[str, Formulation]
1722
+ ) -> dict[str, dict[str, str]]:
1723
+ """The renamings of the inputs of each inner formulation, by its identifier.
1724
+
1725
+ ``bind = { theta = "t" }`` renames an input of every inner
1726
+ formulation; ``bind = { theta_1 = { T = "T_1" } }`` those of the
1727
+ formulation computing ``theta_1`` only, so that one formulation may
1728
+ compute two inputs at different arguments.
1729
+ """
1730
+ if table is None:
1731
+ table = {}
1732
+ if not isinstance(table, dict):
1733
+ msg = f"{place}.bind: expected a table"
1734
+ raise CatalogError(msg)
1735
+ every: dict[str, str] = {}
1736
+ own: dict[str, dict[str, str]] = {}
1737
+
1738
+ def target(where: str, name: Any) -> str:
1739
+ if not isinstance(name, str) or not name:
1740
+ msg = f"{where}: expected the identifier of an input"
1741
+ raise CatalogError(msg)
1742
+ if name in inner:
1743
+ msg = f"{where}: {name!r} is computed by {inner[name].key}"
1744
+ raise CatalogError(msg)
1745
+ return name
1746
+
1747
+ for source, name in table.items():
1748
+ if isinstance(name, dict):
1749
+ part = inner.get(source)
1750
+ if part is None:
1751
+ msg = f"{place}.bind.{source}: no inner formulation computes {source!r}"
1752
+ raise CatalogError(msg)
1753
+ names = {v.identifier for v in part.inputs}
1754
+ for input_name, renamed in name.items():
1755
+ if input_name not in names:
1756
+ msg = (
1757
+ f"{place}.bind.{source}: {input_name!r} is not an "
1758
+ f"input of {part.key}"
1759
+ )
1760
+ raise CatalogError(msg)
1761
+ own.setdefault(source, {})[input_name] = target(
1762
+ f"{place}.bind.{source}", renamed
1763
+ )
1764
+ continue
1765
+ if not any(
1766
+ source in {v.identifier for v in p.inputs} for p in inner.values()
1767
+ ):
1768
+ msg = (
1769
+ f"{place}.bind: {source!r} is not an input of an inner formulation"
1770
+ )
1771
+ raise CatalogError(msg)
1772
+ every[source] = target(f"{place}.bind", name)
1773
+ renames = {}
1774
+ for identifier, part in inner.items():
1775
+ rename = {**every, **own.get(identifier, {})}
1776
+ renames[identifier] = {
1777
+ v.identifier: rename[v.identifier]
1778
+ for v in part.inputs
1779
+ if v.identifier in rename
1780
+ }
1781
+ return renames
1782
+
1716
1783
  def composed(
1717
1784
  self, key: str, table: Any, formulations: Mapping[str, Formulation]
1718
1785
  ) -> Formulation:
@@ -1741,6 +1808,7 @@ class _Builder:
1741
1808
  if isinstance(node, sympy.Piecewise)
1742
1809
  }
1743
1810
  inner: dict[str, Formulation] = {}
1811
+ scaled: dict[str, Variable] = {}
1744
1812
  for identifier, name in c["inner"].items():
1745
1813
  w = f"{place}.inner.{identifier}"
1746
1814
  part = self.part(w, name, formulations)
@@ -1769,33 +1837,26 @@ class _Builder:
1769
1837
  )
1770
1838
  raise CatalogError(msg)
1771
1839
  if part.output.scale != target.scale:
1772
- msg = (
1773
- f"{w}: {part.key} gives a value on scale "
1774
- f"{getattr(part.output.scale, 'key', None)}, but {identifier} "
1775
- f"is on {getattr(target.scale, 'key', None)}"
1776
- )
1777
- raise CatalogError(msg)
1840
+ # A part on no scale (an interpolation, a definition) feeds an
1841
+ # input on the reference scale, where nothing is converted: its
1842
+ # temperatures take that scale. On another scale, converting
1843
+ # its inputs would not convert what it computes.
1844
+ if (
1845
+ part.output.scale is None
1846
+ and target.scale is not None
1847
+ and target.scale.is_reference
1848
+ and not any(v.scale is not None for v in part.inputs)
1849
+ ):
1850
+ scaled[identifier] = target
1851
+ else:
1852
+ msg = (
1853
+ f"{w}: {part.key} gives a value on scale "
1854
+ f"{getattr(part.output.scale, 'key', None)}, but {identifier} "
1855
+ f"is on {getattr(target.scale, 'key', None)}"
1856
+ )
1857
+ raise CatalogError(msg)
1778
1858
  inner[identifier] = part
1779
- bind = dict(c["bind"] or {})
1780
- for source, name in bind.items():
1781
- if not any(
1782
- source in {v.identifier for v in p.inputs} for p in inner.values()
1783
- ):
1784
- msg = (
1785
- f"{place}.bind: {source!r} is not an input of an inner formulation"
1786
- )
1787
- raise CatalogError(msg)
1788
- if name in inner:
1789
- msg = f"{place}.bind: {name!r} is computed by {inner[name].key}"
1790
- raise CatalogError(msg)
1791
- renames = {
1792
- identifier: {
1793
- v.identifier: bind[v.identifier]
1794
- for v in part.inputs
1795
- if v.identifier in bind
1796
- }
1797
- for identifier, part in inner.items()
1798
- }
1859
+ renames = self.bindings(place, c["bind"], inner)
1799
1860
 
1800
1861
  # Inputs: those of the outer formulation it still needs, then those of
1801
1862
  # the inner ones; one identifier is one input, alike in every part.
@@ -1804,22 +1865,40 @@ class _Builder:
1804
1865
  pieces = [(outer, v) for v in outer.inputs if v.identifier not in inner]
1805
1866
  for identifier, part in inner.items():
1806
1867
  rename = renames[identifier]
1807
- pieces += [
1808
- (
1809
- part,
1810
- dataclasses.replace(
1811
- v, identifier=rename.get(v.identifier, v.identifier)
1812
- ),
1868
+ fed = scaled.get(identifier)
1869
+ for v in part.inputs:
1870
+ v = dataclasses.replace(
1871
+ v, identifier=rename.get(v.identifier, v.identifier)
1813
1872
  )
1814
- for v in part.inputs
1815
- ]
1873
+ if v.identifier in inner:
1874
+ # One identifier would name two quantities: an input of
1875
+ # this part, and what another part computes.
1876
+ msg = (
1877
+ f"{where}: input {v.identifier!r} of {part.key} is "
1878
+ f"computed by {inner[v.identifier].key}; rename it with bind"
1879
+ )
1880
+ raise CatalogError(msg)
1881
+ if (
1882
+ fed is not None
1883
+ and fed.scale is not None
1884
+ and fed.scale.applies_to(v.kind, self.kinds)
1885
+ ):
1886
+ v = dataclasses.replace(
1887
+ v, scale=fed.scale, scale_basis=fed.scale_basis
1888
+ )
1889
+ pieces.append((part, v))
1816
1890
  for piece, variable in pieces:
1817
1891
  came_from.setdefault(variable.identifier, piece.key)
1818
1892
  known = inputs.setdefault(variable.identifier, variable)
1819
- if (known.kind, known.unit, known.scale) != (
1820
- variable.kind,
1821
- variable.unit,
1822
- variable.scale,
1893
+ # An input on no scale merges with one on the reference scale
1894
+ # only: on another, its part would receive the converted value.
1895
+ other = variable.scale if known.scale is None else known.scale
1896
+ if (known.kind, known.unit) != (variable.kind, variable.unit) or (
1897
+ known.scale != variable.scale
1898
+ and (
1899
+ None not in (known.scale, variable.scale)
1900
+ or not getattr(other, "is_reference", False)
1901
+ )
1823
1902
  ):
1824
1903
  msg = (
1825
1904
  f"{where}: input {variable.identifier} of {piece.key} differs "
@@ -1827,6 +1906,12 @@ class _Builder:
1827
1906
  "another part; rename it with bind"
1828
1907
  )
1829
1908
  raise CatalogError(msg)
1909
+ if known.scale is None and variable.scale is not None:
1910
+ # An input on no scale takes any: that of the other part.
1911
+ inputs[variable.identifier] = dataclasses.replace(
1912
+ known, scale=variable.scale, scale_basis=variable.scale_basis
1913
+ )
1914
+ came_from[variable.identifier] = piece.key
1830
1915
 
1831
1916
  # Part, and formulation that defines it, of each coefficient.
1832
1917
  owner: dict[str, tuple[str, str]] = {}
@@ -2737,6 +2822,11 @@ class _Builder:
2737
2822
  for name in part.rename.values()
2738
2823
  if name not in inputs
2739
2824
  }
2825
+ elif formulation.parts:
2826
+ # A composition: checked on the parts substituted as written,
2827
+ # since SymPy may simplify a factor of two calls of a part away.
2828
+ tree = ast.parse(formulation.expression, mode="eval")
2829
+ used = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
2740
2830
  unused = sorted(set(names) - used)
2741
2831
  if unused:
2742
2832
  msg = f"{where}: declared but not used: {', '.join(unused)}"
@@ -2790,8 +2880,27 @@ class _Builder:
2790
2880
  built[key] = self.composed(key, tables[key], built)
2791
2881
  formulations = {key: built[key] for key in tables}
2792
2882
  # Two sources of one formulation make one entry with two citations.
2883
+ # Only formulations of the same head (kinds, units, coefficients),
2884
+ # then of alike shapes, may be one: the costly signature, which
2885
+ # tries every order of the inputs, is computed for those alone.
2886
+ heads: dict[str, list[str]] = {}
2887
+ for key, f in formulations.items():
2888
+ heads.setdefault(_head(f), []).append(key)
2889
+ candidates = []
2890
+ for keys in heads.values():
2891
+ shapes = {key: _shape(formulations[key]) for key in keys if len(keys) > 1}
2892
+ candidates += [
2893
+ key
2894
+ for key in shapes
2895
+ if any(
2896
+ _alike(shapes[key], shapes[other])
2897
+ for other in shapes
2898
+ if other != key
2899
+ )
2900
+ ]
2793
2901
  seen: dict[str, str] = {}
2794
- for key, formulation in formulations.items():
2902
+ for key in candidates:
2903
+ formulation = formulations[key]
2795
2904
  fingerprint = signature(formulation)
2796
2905
  if fingerprint in seen:
2797
2906
  first = seen[fingerprint]