nomogram 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomogram/__init__.py +17 -0
- nomogram/catalog.py +533 -0
- nomogram/comparison.py +305 -0
- nomogram/data/NOTICE.md +25 -0
- nomogram/data/catalog/humidity.toml +534 -0
- nomogram/data/semantics.json +464 -0
- nomogram/expression.py +232 -0
- nomogram/model.py +594 -0
- nomogram/plot.py +176 -0
- nomogram/py.typed +0 -0
- nomogram/semantics.py +333 -0
- nomogram/units.py +118 -0
- nomogram-0.1.0.dist-info/METADATA +125 -0
- nomogram-0.1.0.dist-info/RECORD +17 -0
- nomogram-0.1.0.dist-info/WHEEL +4 -0
- nomogram-0.1.0.dist-info/licenses/LICENSE +28 -0
- nomogram-0.1.0.dist-info/licenses/src/nomogram/data/NOTICE.md +25 -0
nomogram/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Miki
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""nomogram: a repository of scientific formulas.
|
|
5
|
+
|
|
6
|
+
Submodules are imported on demand, so that ``import nomogram`` stays
|
|
7
|
+
light: :mod:`nomogram.units` (pint), :mod:`nomogram.expression`
|
|
8
|
+
(SymPy), :mod:`nomogram.semantics` (bundled QUDT and CF subsets),
|
|
9
|
+
:mod:`nomogram.model` and :mod:`nomogram.catalog` (formulations), and
|
|
10
|
+
:mod:`nomogram.comparison`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import version
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
16
|
+
|
|
17
|
+
__version__ = version("nomogram")
|
nomogram/catalog.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Miki
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""The formula catalog: hand-written TOML, validated when loaded.
|
|
5
|
+
|
|
6
|
+
The bundled catalog lives in ``src/nomogram/data/catalog/*.toml`` and is
|
|
7
|
+
read with :mod:`tomllib`. Loading checks everything a reader could get
|
|
8
|
+
wrong: every identifier of an expression is declared and used, units and
|
|
9
|
+
quantity kinds exist, each unit applies to its kind, cited references and
|
|
10
|
+
constants exist, domains name real inputs, and quantity equations are
|
|
11
|
+
dimensionally homogeneous. Unknown keys are refused, so that a typo never
|
|
12
|
+
goes unnoticed.
|
|
13
|
+
|
|
14
|
+
Quantity kinds come from the bundled QUDT subset
|
|
15
|
+
(:func:`nomogram.semantics.semantic_catalog`); the catalog may define
|
|
16
|
+
further kinds, each specializing a QUDT kind (see
|
|
17
|
+
:class:`nomogram.semantics.QuantityKind`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Iterable, Mapping
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from importlib.resources import files
|
|
28
|
+
from itertools import pairwise
|
|
29
|
+
from types import MappingProxyType
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import pint
|
|
33
|
+
|
|
34
|
+
from nomogram.expression import ExpressionError
|
|
35
|
+
from nomogram.model import (
|
|
36
|
+
Coefficient,
|
|
37
|
+
DomainBasis,
|
|
38
|
+
Formulation,
|
|
39
|
+
Notation,
|
|
40
|
+
PhysicalConstant,
|
|
41
|
+
Reference,
|
|
42
|
+
ReferenceStatus,
|
|
43
|
+
ScaleBasis,
|
|
44
|
+
TemperatureScale,
|
|
45
|
+
TradeMeaning,
|
|
46
|
+
TradeTerm,
|
|
47
|
+
ValidityDomain,
|
|
48
|
+
Variable,
|
|
49
|
+
)
|
|
50
|
+
from nomogram.semantics import (
|
|
51
|
+
ExternalMatch,
|
|
52
|
+
MatchRelation,
|
|
53
|
+
QuantityKind,
|
|
54
|
+
semantic_catalog,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = ["Catalog", "CatalogError", "catalog", "load_catalog"]
|
|
58
|
+
|
|
59
|
+
_SECTIONS = (
|
|
60
|
+
"kinds",
|
|
61
|
+
"references",
|
|
62
|
+
"constants",
|
|
63
|
+
"notations",
|
|
64
|
+
"trade_terms",
|
|
65
|
+
"temperature_scales",
|
|
66
|
+
"formulations",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
#: Kinds that may carry a temperature scale, and the units allowed for them.
|
|
70
|
+
_SCALED_KINDS = frozenset({"ThermodynamicTemperature", "CelsiusTemperature"})
|
|
71
|
+
_SCALED_UNITS = frozenset({"K", "DEG_C"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CatalogError(ValueError):
|
|
75
|
+
"""The catalog is invalid; the message names the faulty entry."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Catalog:
|
|
80
|
+
"""Formulations and everything they refer to, read-only.
|
|
81
|
+
|
|
82
|
+
Attributes
|
|
83
|
+
----------
|
|
84
|
+
kinds : mapping of str to QuantityKind
|
|
85
|
+
QUDT kinds and the kinds defined by the catalog, by name.
|
|
86
|
+
formulations : mapping of str to Formulation
|
|
87
|
+
By key.
|
|
88
|
+
references : mapping of str to Reference
|
|
89
|
+
By key.
|
|
90
|
+
constants : mapping of str to PhysicalConstant
|
|
91
|
+
By key.
|
|
92
|
+
notations : mapping of str to Notation
|
|
93
|
+
By key.
|
|
94
|
+
trade_terms : mapping of str to TradeTerm
|
|
95
|
+
By key.
|
|
96
|
+
temperature_scales : mapping of str to TemperatureScale
|
|
97
|
+
By key.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
kinds: Mapping[str, QuantityKind]
|
|
101
|
+
formulations: Mapping[str, Formulation]
|
|
102
|
+
references: Mapping[str, Reference]
|
|
103
|
+
constants: Mapping[str, PhysicalConstant]
|
|
104
|
+
notations: Mapping[str, Notation]
|
|
105
|
+
trade_terms: Mapping[str, TradeTerm]
|
|
106
|
+
temperature_scales: Mapping[str, TemperatureScale]
|
|
107
|
+
|
|
108
|
+
def formulations_of(self, kind: str) -> tuple[Formulation, ...]:
|
|
109
|
+
"""The alternative formulations computing a quantity kind."""
|
|
110
|
+
return tuple(f for f in self.formulations.values() if f.output.kind == kind)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fields(
|
|
114
|
+
where: str, table: Any, required: Iterable[str], optional: Iterable[str] = ()
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
if not isinstance(table, dict):
|
|
117
|
+
msg = f"{where}: expected a table"
|
|
118
|
+
raise CatalogError(msg)
|
|
119
|
+
required, optional = tuple(required), tuple(optional)
|
|
120
|
+
unknown = sorted(set(table) - set(required) - set(optional))
|
|
121
|
+
if unknown:
|
|
122
|
+
msg = f"{where}: unknown keys: {', '.join(unknown)}"
|
|
123
|
+
raise CatalogError(msg)
|
|
124
|
+
missing = sorted(set(required) - set(table))
|
|
125
|
+
if missing:
|
|
126
|
+
msg = f"{where}: missing keys: {', '.join(missing)}"
|
|
127
|
+
raise CatalogError(msg)
|
|
128
|
+
return {k: table.get(k) for k in required + optional}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _enum(where: str, enum: Any, value: Any) -> Any:
|
|
132
|
+
try:
|
|
133
|
+
return enum(value)
|
|
134
|
+
except ValueError:
|
|
135
|
+
allowed = ", ".join(e.value for e in enum)
|
|
136
|
+
msg = f"{where}: {value!r} is not one of {allowed}"
|
|
137
|
+
raise CatalogError(msg) from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _matches(where: str, items: Any) -> tuple[ExternalMatch, ...]:
|
|
141
|
+
result = []
|
|
142
|
+
for i, item in enumerate(items or ()):
|
|
143
|
+
f = _fields(f"{where}.matches[{i}]", item, ("target", "relation", "verified"))
|
|
144
|
+
if not isinstance(f["verified"], datetime.date):
|
|
145
|
+
msg = f"{where}.matches[{i}].verified: expected a date (YYYY-MM-DD)"
|
|
146
|
+
raise CatalogError(msg)
|
|
147
|
+
relation = _enum(f"{where}.matches[{i}].relation", MatchRelation, f["relation"])
|
|
148
|
+
result.append(ExternalMatch(f["target"], relation, f["verified"]))
|
|
149
|
+
return tuple(result)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Builder:
|
|
153
|
+
def __init__(self, data: dict[str, dict[str, Any]]) -> None:
|
|
154
|
+
self.data = data
|
|
155
|
+
self.semantics = semantic_catalog()
|
|
156
|
+
self.kinds: dict[str, QuantityKind] = dict(self.semantics.quantity_kinds)
|
|
157
|
+
self.references: dict[str, Reference] = {}
|
|
158
|
+
self.constants: dict[str, PhysicalConstant] = {}
|
|
159
|
+
self.scales: dict[str, TemperatureScale] = {}
|
|
160
|
+
|
|
161
|
+
def reference(self, where: str, key: Any) -> str:
|
|
162
|
+
if key not in self.references:
|
|
163
|
+
msg = f"{where}: unknown reference {key!r}"
|
|
164
|
+
raise CatalogError(msg)
|
|
165
|
+
return str(key)
|
|
166
|
+
|
|
167
|
+
def optional_reference(self, where: str, key: Any) -> str | None:
|
|
168
|
+
return None if key is None else self.reference(where, key)
|
|
169
|
+
|
|
170
|
+
def unit(self, where: str, name: Any) -> str:
|
|
171
|
+
if name not in self.semantics.units:
|
|
172
|
+
msg = f"{where}: unknown unit {name!r}"
|
|
173
|
+
raise CatalogError(msg)
|
|
174
|
+
return str(name)
|
|
175
|
+
|
|
176
|
+
def build_kinds(self) -> None:
|
|
177
|
+
for name, table in self.data["kinds"].items():
|
|
178
|
+
where = f"kinds.{name}"
|
|
179
|
+
f = _fields(
|
|
180
|
+
where, table, ("label", "specialization_of"), ("description", "matches")
|
|
181
|
+
)
|
|
182
|
+
if name in self.kinds:
|
|
183
|
+
msg = f"{where}: already a QUDT kind"
|
|
184
|
+
raise CatalogError(msg)
|
|
185
|
+
parent = self.kinds.get(f["specialization_of"])
|
|
186
|
+
if parent is None:
|
|
187
|
+
msg = f"{where}.specialization_of: unknown kind {f['specialization_of']!r}"
|
|
188
|
+
raise CatalogError(msg)
|
|
189
|
+
self.kinds[name] = QuantityKind(
|
|
190
|
+
name=name,
|
|
191
|
+
origin="nomogram",
|
|
192
|
+
iri=None,
|
|
193
|
+
label=f["label"],
|
|
194
|
+
dimension_vector=parent.dimension_vector,
|
|
195
|
+
specialization_of=parent.name,
|
|
196
|
+
scale_type=parent.scale_type,
|
|
197
|
+
delta=parent.delta,
|
|
198
|
+
cf_standard_names=(),
|
|
199
|
+
applicable_units=parent.applicable_units,
|
|
200
|
+
added_units=(),
|
|
201
|
+
matches=_matches(where, f["matches"]),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def build_references(self) -> None:
|
|
205
|
+
for key, table in self.data["references"].items():
|
|
206
|
+
where = f"references.{key}"
|
|
207
|
+
f = _fields(
|
|
208
|
+
where,
|
|
209
|
+
table,
|
|
210
|
+
("authors", "title", "status"),
|
|
211
|
+
("year", "container", "doi", "url", "note", "matches"),
|
|
212
|
+
)
|
|
213
|
+
self.references[key] = Reference(
|
|
214
|
+
key=key,
|
|
215
|
+
authors=f["authors"],
|
|
216
|
+
year=f["year"],
|
|
217
|
+
title=f["title"],
|
|
218
|
+
container=f["container"],
|
|
219
|
+
doi=f["doi"],
|
|
220
|
+
url=f["url"],
|
|
221
|
+
status=_enum(f"{where}.status", ReferenceStatus, f["status"]),
|
|
222
|
+
note=f["note"],
|
|
223
|
+
matches=_matches(where, f["matches"]),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def build_constants(self) -> None:
|
|
227
|
+
for key, table in self.data["constants"].items():
|
|
228
|
+
where = f"constants.{key}"
|
|
229
|
+
f = _fields(
|
|
230
|
+
where,
|
|
231
|
+
table,
|
|
232
|
+
("identifier", "value", "unit", "description", "reference"),
|
|
233
|
+
("matches",),
|
|
234
|
+
)
|
|
235
|
+
self.constants[key] = PhysicalConstant(
|
|
236
|
+
key=key,
|
|
237
|
+
identifier=f["identifier"],
|
|
238
|
+
value=float(f["value"]),
|
|
239
|
+
unit=self.unit(f"{where}.unit", f["unit"]),
|
|
240
|
+
description=f["description"],
|
|
241
|
+
reference=self.reference(f"{where}.reference", f["reference"]),
|
|
242
|
+
matches=_matches(where, f["matches"]),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def build_notations(self) -> dict[str, Notation]:
|
|
246
|
+
notations = {}
|
|
247
|
+
for key, table in self.data["notations"].items():
|
|
248
|
+
where = f"notations.{key}"
|
|
249
|
+
f = _fields(where, table, ("label", "symbols"), ("reference",))
|
|
250
|
+
notations[key] = Notation(
|
|
251
|
+
key=key,
|
|
252
|
+
label=f["label"],
|
|
253
|
+
symbols=MappingProxyType(dict(f["symbols"])),
|
|
254
|
+
reference=self.optional_reference(f"{where}.reference", f["reference"]),
|
|
255
|
+
)
|
|
256
|
+
return notations
|
|
257
|
+
|
|
258
|
+
def build_trade_terms(self) -> dict[str, TradeTerm]:
|
|
259
|
+
terms = {}
|
|
260
|
+
for key, table in self.data["trade_terms"].items():
|
|
261
|
+
where = f"trade_terms.{key}"
|
|
262
|
+
f = _fields(where, table, ("term", "language", "meanings"), ("matches",))
|
|
263
|
+
meanings = []
|
|
264
|
+
for i, item in enumerate(f["meanings"]):
|
|
265
|
+
w = f"{where}.meanings[{i}]"
|
|
266
|
+
m = _fields(w, item, ("community", "kind", "definition", "sources"))
|
|
267
|
+
self.kind(f"{w}.kind", m["kind"])
|
|
268
|
+
sources = tuple(self.reference(f"{w}.sources", s) for s in m["sources"])
|
|
269
|
+
meanings.append(
|
|
270
|
+
TradeMeaning(m["community"], m["kind"], m["definition"], sources)
|
|
271
|
+
)
|
|
272
|
+
terms[key] = TradeTerm(
|
|
273
|
+
key=key,
|
|
274
|
+
term=f["term"],
|
|
275
|
+
language=f["language"],
|
|
276
|
+
meanings=tuple(meanings),
|
|
277
|
+
matches=_matches(where, f["matches"]),
|
|
278
|
+
)
|
|
279
|
+
return terms
|
|
280
|
+
|
|
281
|
+
def kind(self, where: str, name: Any) -> QuantityKind:
|
|
282
|
+
if name not in self.kinds:
|
|
283
|
+
msg = f"{where}: unknown kind {name!r}"
|
|
284
|
+
raise CatalogError(msg)
|
|
285
|
+
return self.kinds[name]
|
|
286
|
+
|
|
287
|
+
def build_scales(self) -> None:
|
|
288
|
+
for key, table in self.data["temperature_scales"].items():
|
|
289
|
+
where = f"temperature_scales.{key}"
|
|
290
|
+
f = _fields(where, table, ("label",), ("differences", "reference"))
|
|
291
|
+
pairs = [tuple(float(x) for x in row) for row in f["differences"] or ()]
|
|
292
|
+
if any(len(row) != 2 for row in pairs):
|
|
293
|
+
msg = f"{where}.differences: expected [t90, t90 - t] pairs"
|
|
294
|
+
raise CatalogError(msg)
|
|
295
|
+
t90 = tuple(row[0] for row in pairs)
|
|
296
|
+
if any(b <= a for a, b in pairwise(t90)):
|
|
297
|
+
msg = f"{where}.differences: t90 must increase"
|
|
298
|
+
raise CatalogError(msg)
|
|
299
|
+
self.scales[key] = TemperatureScale(
|
|
300
|
+
key=key,
|
|
301
|
+
label=f["label"],
|
|
302
|
+
t90=t90,
|
|
303
|
+
difference=tuple(row[1] for row in pairs),
|
|
304
|
+
reference=self.optional_reference(f"{where}.reference", f["reference"]),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def variable(self, where: str, table: Any) -> Variable:
|
|
308
|
+
f = _fields(
|
|
309
|
+
where,
|
|
310
|
+
table,
|
|
311
|
+
("identifier", "kind", "unit", "description"),
|
|
312
|
+
("temperature_scale", "scale_basis"),
|
|
313
|
+
)
|
|
314
|
+
kind = self.kind(f"{where}.kind", f["kind"])
|
|
315
|
+
unit = self.unit(f"{where}.unit", f["unit"])
|
|
316
|
+
if unit not in kind.applicable_units:
|
|
317
|
+
msg = f"{where}.unit: {unit} does not apply to {kind.name}"
|
|
318
|
+
raise CatalogError(msg)
|
|
319
|
+
scale, basis = f["temperature_scale"], f["scale_basis"]
|
|
320
|
+
if (scale is None) != (basis is None):
|
|
321
|
+
msg = f"{where}: temperature_scale and scale_basis go together"
|
|
322
|
+
raise CatalogError(msg)
|
|
323
|
+
if scale is not None:
|
|
324
|
+
if kind.name not in _SCALED_KINDS or unit not in _SCALED_UNITS:
|
|
325
|
+
msg = f"{where}: a temperature scale needs a temperature in K or DEG_C"
|
|
326
|
+
raise CatalogError(msg)
|
|
327
|
+
if scale not in self.scales:
|
|
328
|
+
msg = f"{where}.temperature_scale: unknown scale {scale!r}"
|
|
329
|
+
raise CatalogError(msg)
|
|
330
|
+
return Variable(
|
|
331
|
+
f["identifier"],
|
|
332
|
+
kind.name,
|
|
333
|
+
unit,
|
|
334
|
+
f["description"],
|
|
335
|
+
self.scales[scale],
|
|
336
|
+
_enum(f"{where}.scale_basis", ScaleBasis, basis),
|
|
337
|
+
)
|
|
338
|
+
return Variable(f["identifier"], kind.name, unit, f["description"])
|
|
339
|
+
|
|
340
|
+
def coefficients(self, where: str, table: Any) -> tuple[Coefficient, ...]:
|
|
341
|
+
result = []
|
|
342
|
+
for identifier, value in (table or {}).items():
|
|
343
|
+
if isinstance(value, dict):
|
|
344
|
+
f = _fields(
|
|
345
|
+
f"{where}.{identifier}", value, ("value",), ("unit", "note")
|
|
346
|
+
)
|
|
347
|
+
unit = f["unit"]
|
|
348
|
+
if unit is not None:
|
|
349
|
+
unit = self.unit(f"{where}.{identifier}.unit", unit)
|
|
350
|
+
result.append(
|
|
351
|
+
Coefficient(identifier, float(f["value"]), unit, f["note"])
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
result.append(Coefficient(identifier, float(value), None, None))
|
|
355
|
+
return tuple(result)
|
|
356
|
+
|
|
357
|
+
def domains(
|
|
358
|
+
self, where: str, items: Any, inputs: tuple[Variable, ...]
|
|
359
|
+
) -> tuple[ValidityDomain, ...]:
|
|
360
|
+
names = {v.identifier for v in inputs}
|
|
361
|
+
result = []
|
|
362
|
+
for i, item in enumerate(items or ()):
|
|
363
|
+
w = f"{where}[{i}]"
|
|
364
|
+
f = _fields(w, item, ("basis", "bounds"), ("criterion", "reference"))
|
|
365
|
+
bounds = {}
|
|
366
|
+
for identifier, interval in f["bounds"].items():
|
|
367
|
+
if identifier not in names:
|
|
368
|
+
msg = f"{w}.bounds: {identifier!r} is not an input"
|
|
369
|
+
raise CatalogError(msg)
|
|
370
|
+
low, high = (float(x) for x in interval)
|
|
371
|
+
if not low < high:
|
|
372
|
+
msg = f"{w}.bounds.{identifier}: empty interval"
|
|
373
|
+
raise CatalogError(msg)
|
|
374
|
+
bounds[identifier] = (low, high)
|
|
375
|
+
result.append(
|
|
376
|
+
ValidityDomain(
|
|
377
|
+
basis=_enum(f"{w}.basis", DomainBasis, f["basis"]),
|
|
378
|
+
bounds=MappingProxyType(bounds),
|
|
379
|
+
criterion=f["criterion"],
|
|
380
|
+
reference=self.optional_reference(f"{w}.reference", f["reference"]),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
return tuple(result)
|
|
384
|
+
|
|
385
|
+
def formulation(self, key: str, table: Any) -> Formulation:
|
|
386
|
+
where = f"formulations.{key}"
|
|
387
|
+
f = _fields(
|
|
388
|
+
where,
|
|
389
|
+
table,
|
|
390
|
+
("label", "expression", "fixed_units", "output", "inputs"),
|
|
391
|
+
(
|
|
392
|
+
"coefficients",
|
|
393
|
+
"constants",
|
|
394
|
+
"domains",
|
|
395
|
+
"accuracy",
|
|
396
|
+
"references",
|
|
397
|
+
"note",
|
|
398
|
+
"matches",
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
output = self.variable(f"{where}.output", f["output"])
|
|
402
|
+
inputs = tuple(
|
|
403
|
+
self.variable(f"{where}.inputs[{i}]", v) for i, v in enumerate(f["inputs"])
|
|
404
|
+
)
|
|
405
|
+
constants = []
|
|
406
|
+
for name in f["constants"] or ():
|
|
407
|
+
if name not in self.constants:
|
|
408
|
+
msg = f"{where}.constants: unknown constant {name!r}"
|
|
409
|
+
raise CatalogError(msg)
|
|
410
|
+
constants.append(self.constants[name])
|
|
411
|
+
formulation = Formulation(
|
|
412
|
+
key=key,
|
|
413
|
+
label=f["label"],
|
|
414
|
+
output=output,
|
|
415
|
+
inputs=inputs,
|
|
416
|
+
coefficients=self.coefficients(f"{where}.coefficients", f["coefficients"]),
|
|
417
|
+
constants=tuple(constants),
|
|
418
|
+
expression=f["expression"],
|
|
419
|
+
fixed_units=bool(f["fixed_units"]),
|
|
420
|
+
domains=self.domains(f"{where}.domains", f["domains"], inputs),
|
|
421
|
+
accuracy=f["accuracy"],
|
|
422
|
+
references=tuple(
|
|
423
|
+
self.reference(f"{where}.references", r) for r in f["references"] or ()
|
|
424
|
+
),
|
|
425
|
+
note=f["note"],
|
|
426
|
+
matches=_matches(where, f["matches"]),
|
|
427
|
+
)
|
|
428
|
+
self.check(where, formulation)
|
|
429
|
+
return formulation
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def check(where: str, formulation: Formulation) -> None:
|
|
433
|
+
names = formulation.names
|
|
434
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
435
|
+
if formulation.output.identifier in names:
|
|
436
|
+
duplicates.append(formulation.output.identifier)
|
|
437
|
+
if duplicates:
|
|
438
|
+
msg = f"{where}: identifiers declared twice: {', '.join(duplicates)}"
|
|
439
|
+
raise CatalogError(msg)
|
|
440
|
+
try:
|
|
441
|
+
expr = formulation.sympy_expression
|
|
442
|
+
except ExpressionError as exc:
|
|
443
|
+
msg = f"{where}.expression: {exc}"
|
|
444
|
+
raise CatalogError(msg) from None
|
|
445
|
+
unused = sorted(set(names) - {s.name for s in expr.free_symbols})
|
|
446
|
+
if unused:
|
|
447
|
+
msg = f"{where}: declared but not used: {', '.join(unused)}"
|
|
448
|
+
raise CatalogError(msg)
|
|
449
|
+
try:
|
|
450
|
+
_ = formulation.output_factor
|
|
451
|
+
except pint.errors.DimensionalityError as exc:
|
|
452
|
+
msg = f"{where}: not dimensionally homogeneous: {exc}"
|
|
453
|
+
raise CatalogError(msg) from None
|
|
454
|
+
|
|
455
|
+
def build(self) -> Catalog:
|
|
456
|
+
self.build_kinds()
|
|
457
|
+
self.build_references()
|
|
458
|
+
self.build_constants()
|
|
459
|
+
self.build_scales()
|
|
460
|
+
notations = self.build_notations()
|
|
461
|
+
trade_terms = self.build_trade_terms()
|
|
462
|
+
formulations = {
|
|
463
|
+
key: self.formulation(key, table)
|
|
464
|
+
for key, table in self.data["formulations"].items()
|
|
465
|
+
}
|
|
466
|
+
return Catalog(
|
|
467
|
+
kinds=MappingProxyType(self.kinds),
|
|
468
|
+
formulations=MappingProxyType(formulations),
|
|
469
|
+
references=MappingProxyType(self.references),
|
|
470
|
+
constants=MappingProxyType(self.constants),
|
|
471
|
+
notations=MappingProxyType(notations),
|
|
472
|
+
trade_terms=MappingProxyType(trade_terms),
|
|
473
|
+
temperature_scales=MappingProxyType(self.scales),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def load_catalog(sources: Iterable[str]) -> Catalog:
|
|
478
|
+
"""Build a catalog from TOML texts, merged section by section.
|
|
479
|
+
|
|
480
|
+
Parameters
|
|
481
|
+
----------
|
|
482
|
+
sources : iterable of str
|
|
483
|
+
TOML documents with the sections ``kinds``, ``references``,
|
|
484
|
+
``constants``, ``notations``, ``trade_terms``,
|
|
485
|
+
``temperature_scales`` and ``formulations`` (all optional).
|
|
486
|
+
|
|
487
|
+
Returns
|
|
488
|
+
-------
|
|
489
|
+
Catalog
|
|
490
|
+
The validated catalog.
|
|
491
|
+
|
|
492
|
+
Raises
|
|
493
|
+
------
|
|
494
|
+
CatalogError
|
|
495
|
+
If a document is not valid TOML, an entry is defined twice, or any
|
|
496
|
+
check fails; the message names the faulty entry.
|
|
497
|
+
"""
|
|
498
|
+
data: dict[str, dict[str, Any]] = {section: {} for section in _SECTIONS}
|
|
499
|
+
for number, text in enumerate(sources, start=1):
|
|
500
|
+
try:
|
|
501
|
+
document = tomllib.loads(text)
|
|
502
|
+
except tomllib.TOMLDecodeError as exc:
|
|
503
|
+
msg = f"document {number}: invalid TOML: {exc}"
|
|
504
|
+
raise CatalogError(msg) from None
|
|
505
|
+
unknown = sorted(set(document) - set(_SECTIONS))
|
|
506
|
+
if unknown:
|
|
507
|
+
msg = f"document {number}: unknown sections: {', '.join(unknown)}"
|
|
508
|
+
raise CatalogError(msg)
|
|
509
|
+
for section, entries in document.items():
|
|
510
|
+
twice = sorted(set(entries) & set(data[section]))
|
|
511
|
+
if twice:
|
|
512
|
+
msg = f"{section}: defined twice: {', '.join(twice)}"
|
|
513
|
+
raise CatalogError(msg)
|
|
514
|
+
data[section].update(entries)
|
|
515
|
+
return _Builder(data).build()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@lru_cache(maxsize=1)
|
|
519
|
+
def catalog() -> Catalog:
|
|
520
|
+
"""Return the bundled catalog, read and validated once.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
Catalog
|
|
525
|
+
Formulations, references, constants, notations and trade terms.
|
|
526
|
+
"""
|
|
527
|
+
folder = files("nomogram") / "data" / "catalog"
|
|
528
|
+
texts = [
|
|
529
|
+
entry.read_text(encoding="utf-8")
|
|
530
|
+
for entry in sorted(folder.iterdir(), key=lambda e: e.name)
|
|
531
|
+
if entry.name.endswith(".toml")
|
|
532
|
+
]
|
|
533
|
+
return load_catalog(texts)
|