nomogram 0.4.0__tar.gz → 0.5.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 (30) hide show
  1. {nomogram-0.4.0 → nomogram-0.5.0}/PKG-INFO +39 -18
  2. {nomogram-0.4.0 → nomogram-0.5.0}/README.md +38 -17
  3. {nomogram-0.4.0 → nomogram-0.5.0}/pyproject.toml +1 -1
  4. {nomogram-0.4.0 → nomogram-0.5.0}/pyproject.toml.orig +1 -1
  5. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/catalog.py +150 -41
  6. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/comparison.py +161 -6
  7. nomogram-0.5.0/src/nomogram/data/catalog/atmosphere.toml +1590 -0
  8. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/core.toml +40 -2
  9. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/moist-air.toml +341 -1
  10. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/radio.toml +1 -22
  11. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/semantics.json +343 -74
  12. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/dimensions.py +24 -2
  13. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/display.py +6 -2
  14. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/expression.py +19 -3
  15. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/inversion.py +3 -1
  16. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/model.py +14 -3
  17. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/plot.py +279 -19
  18. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/relations.py +129 -30
  19. {nomogram-0.4.0 → nomogram-0.5.0}/LICENSE +0 -0
  20. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/__init__.py +0 -0
  21. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/_logo.py +0 -0
  22. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/NOTICE.md +0 -0
  23. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/humidity.toml +0 -0
  24. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/sea-water.toml +0 -0
  25. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/verifications.json +0 -0
  26. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/data/catalog/water-vapour.toml +0 -0
  27. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/py.typed +0 -0
  28. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/search.py +0 -0
  29. {nomogram-0.4.0 → nomogram-0.5.0}/src/nomogram/semantics.py +0 -0
  30. {nomogram-0.4.0 → nomogram-0.5.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.5.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
@@ -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.5.0/docs/images/banner-dark.svg">
38
+ <img alt="nomogram" src="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.5.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.5.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.5.0/pyproject.toml)
48
+ [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](https://github.com/miki4iaml/nomogram/blob/v0.5.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.5.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.5.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.5.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.5.0/docs/search.md)):
134
155
 
135
156
  ```python
136
157
  catalog.search(variable="rho*") # ρ in every notation
@@ -138,20 +159,20 @@ 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.5.0/docs/rosetta.md);
163
+ [How nomogram earns trust](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/approach.md) explains what is checked and
164
+ how, and [Displaying formulas](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/display.md) shows formulas in notebooks,
165
+ Markdown and plots. [Sea water](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/sea-water.md) shows the salinity
145
166
  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
167
+ changes at the sea surface. [The logo](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/logo.md) is a working
147
168
  nomogram.
148
169
 
149
170
  ## Contributing
150
171
 
151
172
  References to integrate, information or corrections about catalog
152
173
  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).
174
+ [ways to contribute](https://github.com/miki4iaml/nomogram/blob/v0.5.0/.github/CONTRIBUTING.md#ways-to-contribute) and
175
+ the [catalog guide](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/catalog.md).
155
176
 
156
177
  ## Data sources
157
178
 
@@ -170,9 +191,9 @@ bundles a subset of each source:
170
191
  Institute, Inc., [UCUM License](https://ucum.org/license).
171
192
 
172
193
  What nomogram selects, changes and adds is listed in
173
- [NOTICE.md](src/nomogram/data/NOTICE.md).
194
+ [NOTICE.md](https://github.com/miki4iaml/nomogram/blob/v0.5.0/src/nomogram/data/NOTICE.md).
174
195
 
175
196
  ## License
176
197
 
177
- Code: [BSD-3-Clause](LICENSE). Bundled data: see
198
+ Code: [BSD-3-Clause](https://github.com/miki4iaml/nomogram/blob/v0.5.0/LICENSE). Bundled data: see
178
199
  [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.5.0/docs/images/banner-dark.svg">
4
+ <img alt="nomogram" src="https://raw.githubusercontent.com/miki4iaml/nomogram/v0.5.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.5.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.5.0/pyproject.toml)
14
+ [![License](https://img.shields.io/github/license/miki4iaml/nomogram)](https://github.com/miki4iaml/nomogram/blob/v0.5.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.5.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.5.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.5.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.5.0/docs/search.md)):
100
121
 
101
122
  ```python
102
123
  catalog.search(variable="rho*") # ρ in every notation
@@ -104,20 +125,20 @@ 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.5.0/docs/rosetta.md);
129
+ [How nomogram earns trust](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/approach.md) explains what is checked and
130
+ how, and [Displaying formulas](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/display.md) shows formulas in notebooks,
131
+ Markdown and plots. [Sea water](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/sea-water.md) shows the salinity
111
132
  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
133
+ changes at the sea surface. [The logo](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/logo.md) is a working
113
134
  nomogram.
114
135
 
115
136
  ## Contributing
116
137
 
117
138
  References to integrate, information or corrections about catalog
118
139
  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).
140
+ [ways to contribute](https://github.com/miki4iaml/nomogram/blob/v0.5.0/.github/CONTRIBUTING.md#ways-to-contribute) and
141
+ the [catalog guide](https://github.com/miki4iaml/nomogram/blob/v0.5.0/docs/catalog.md).
121
142
 
122
143
  ## Data sources
123
144
 
@@ -136,9 +157,9 @@ bundles a subset of each source:
136
157
  Institute, Inc., [UCUM License](https://ucum.org/license).
137
158
 
138
159
  What nomogram selects, changes and adds is listed in
139
- [NOTICE.md](src/nomogram/data/NOTICE.md).
160
+ [NOTICE.md](https://github.com/miki4iaml/nomogram/blob/v0.5.0/src/nomogram/data/NOTICE.md).
140
161
 
141
162
  ## License
142
163
 
143
- Code: [BSD-3-Clause](LICENSE). Bundled data: see
164
+ Code: [BSD-3-Clause](https://github.com/miki4iaml/nomogram/blob/v0.5.0/LICENSE). Bundled data: see
144
165
  [Data sources](#data-sources).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.4.0"
3
+ version = "0.5.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.4.0"
3
+ version = "0.5.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
 
@@ -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]