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 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)