nomogram 0.2.0__tar.gz → 0.4.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 (38) hide show
  1. {nomogram-0.2.0 → nomogram-0.4.0}/PKG-INFO +70 -20
  2. nomogram-0.4.0/README.md +144 -0
  3. {nomogram-0.2.0 → nomogram-0.4.0}/pyproject.toml +5 -1
  4. {nomogram-0.2.0 → nomogram-0.4.0}/pyproject.toml.orig +10 -1
  5. nomogram-0.4.0/src/nomogram/__init__.py +72 -0
  6. nomogram-0.4.0/src/nomogram/_logo.py +228 -0
  7. nomogram-0.4.0/src/nomogram/catalog.py +3509 -0
  8. {nomogram-0.2.0 → nomogram-0.4.0}/src/nomogram/comparison.py +29 -5
  9. nomogram-0.4.0/src/nomogram/data/NOTICE.md +40 -0
  10. nomogram-0.4.0/src/nomogram/data/catalog/core.toml +298 -0
  11. nomogram-0.4.0/src/nomogram/data/catalog/humidity.toml +12 -0
  12. nomogram-0.4.0/src/nomogram/data/catalog/moist-air.toml +2056 -0
  13. nomogram-0.4.0/src/nomogram/data/catalog/radio.toml +326 -0
  14. nomogram-0.4.0/src/nomogram/data/catalog/sea-water.toml +658 -0
  15. nomogram-0.4.0/src/nomogram/data/catalog/verifications.json +205 -0
  16. nomogram-0.4.0/src/nomogram/data/catalog/water-vapour.toml +1080 -0
  17. nomogram-0.4.0/src/nomogram/data/semantics.json +1426 -0
  18. nomogram-0.4.0/src/nomogram/dimensions.py +622 -0
  19. nomogram-0.4.0/src/nomogram/display.py +807 -0
  20. nomogram-0.4.0/src/nomogram/expression.py +709 -0
  21. {nomogram-0.2.0 → nomogram-0.4.0}/src/nomogram/inversion.py +323 -54
  22. nomogram-0.4.0/src/nomogram/model.py +1727 -0
  23. nomogram-0.4.0/src/nomogram/plot.py +695 -0
  24. nomogram-0.4.0/src/nomogram/relations.py +1568 -0
  25. nomogram-0.4.0/src/nomogram/search.py +913 -0
  26. {nomogram-0.2.0 → nomogram-0.4.0}/src/nomogram/semantics.py +29 -9
  27. nomogram-0.2.0/README.md +0 -94
  28. nomogram-0.2.0/src/nomogram/__init__.py +0 -17
  29. nomogram-0.2.0/src/nomogram/catalog.py +0 -535
  30. nomogram-0.2.0/src/nomogram/data/NOTICE.md +0 -25
  31. nomogram-0.2.0/src/nomogram/data/catalog/humidity.toml +0 -807
  32. nomogram-0.2.0/src/nomogram/data/semantics.json +0 -513
  33. nomogram-0.2.0/src/nomogram/expression.py +0 -232
  34. nomogram-0.2.0/src/nomogram/model.py +0 -646
  35. nomogram-0.2.0/src/nomogram/plot.py +0 -176
  36. {nomogram-0.2.0 → nomogram-0.4.0}/LICENSE +0 -0
  37. {nomogram-0.2.0 → nomogram-0.4.0}/src/nomogram/py.typed +0 -0
  38. {nomogram-0.2.0 → nomogram-0.4.0}/src/nomogram/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nomogram
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -32,7 +32,12 @@ Project-URL: Changelog, https://github.com/miki4iaml/nomogram/blob/main/CHANGELO
32
32
  Provides-Extra: viz
33
33
  Description-Content-Type: text/markdown
34
34
 
35
- # nomogram
35
+ <p align="center">
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">
39
+ </picture>
40
+ </p>
36
41
 
37
42
  **English** | [Français](README.fr.md)
38
43
 
@@ -47,9 +52,19 @@ Description-Content-Type: text/markdown
47
52
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
48
53
  [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-fe5196)](https://www.conventionalcommits.org/en/v1.0.0/)
49
54
 
50
- A repository of scientific formulas: alternative notations across
51
- authors and conventions, parameter units (including empirical ones),
52
- validity domains, LaTeX rendering and symbolic computation.
55
+ **Formulas as data, not as code.**
56
+
57
+ nomogram does not hard-code functions: it catalogs formulas, each one
58
+
59
+ - **meaningful**: QUDT quantity kinds and units, links to CF and
60
+ Wikidata, its source, its validity domain when stated;
61
+ - **checked**: dimensions and units on loading, equivalences proved
62
+ symbolically, values cross-checked against references;
63
+ - **ready to use**: compute, compose, invert, plot, and compare with its
64
+ alternatives.
65
+
66
+ Built for trust, not for speed: to show that a calculation is right, and
67
+ where it comes from.
53
68
 
54
69
  Pure Python, built on SymPy, numpy and pint; it works on an offline
55
70
  workstation (no online engine).
@@ -77,25 +92,28 @@ Python 3.12 or later. Plots need the `viz` extra (matplotlib):
77
92
  ## Example
78
93
 
79
94
  Every saturation vapour pressure formulation of the catalog against the
80
- IAPWS reference; curves stop where a formulation leaves its stated
81
- validity domain ([script](docs/examples/saturation_vapour_pressure.py)):
95
+ IAPWS reference, from 0 to 60 °C and within 1 %; curves stop where a formulation leaves its
96
+ stated validity domain, and the figure takes the option set of nomogram's
97
+ documentation ([script](docs/examples/saturation_vapour_pressure.py)):
82
98
 
83
99
  ```python
84
100
  import numpy as np
85
- from nomogram.catalog import catalog
86
- from nomogram.comparison import compare
87
- from nomogram.plot import plot_deviations # extra viz
101
+ import nomogram as nomo
88
102
  from nomogram.units import Q_
89
103
 
90
- formulations = catalog().formulations_of("SaturationVapourPressureOverWater")
91
- reference = catalog().formulations["iapws_sr1_86"]
104
+ catalog = nomo.catalog.catalog()
105
+ formulations = catalog.formulations_of("SaturationVapourPressureOverWater")
106
+ reference = catalog.formulations["iapws_sr1_86"]
92
107
  temperatures = Q_(np.linspace(-60, 110, 341), "degC")
93
108
 
94
- table = compare(formulations, reference, {"ThermodynamicTemperature": temperatures})
95
- plot_deviations(table)
109
+ table = nomo.comparison.compare(
110
+ formulations, reference, {"ThermodynamicTemperature": temperatures}
111
+ )
112
+ with nomo.display.display_options(nomo.display.DisplayOptions.DOCUMENTATION):
113
+ nomo.plot.plot_deviations(table, xlim=(0, 60), ylim=(-1.0, 1.0)) # extra viz
96
114
  ```
97
115
 
98
- ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.1.0/docs/images/saturation-vapour-pressure.png)
116
+ ![Deviation of saturation vapour pressure formulations from IAPWS](https://raw.githubusercontent.com/miki4iaml/nomogram/v0.4.0/docs/images/saturation-vapour-pressure.png)
99
117
 
100
118
  ## Usage
101
119
 
@@ -105,16 +123,28 @@ otherwise (`outside="ignore"`). Formulations also chain, for instance
105
123
  the absolute humidity from any saturation vapour pressure formulation:
106
124
 
107
125
  ```python
108
- from nomogram.comparison import compose
109
-
110
- ha = compose(catalog().formulations["mass_concentration_ideal_gas"], {"e_s": reference})
126
+ ha = nomo.comparison.compose(
127
+ catalog.formulations["mass_concentration_ideal_gas"], {"e_s": reference}
128
+ )
111
129
  ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
112
130
  ```
113
131
 
132
+ Formulations are found by words, wildcards and fields, whatever their
133
+ notation ([Finding formulations](docs/search.md)):
134
+
135
+ ```python
136
+ catalog.search(variable="rho*") # ρ in every notation
137
+ catalog.search(kind="VapourPressure", source="buck*")
138
+ ```
139
+
114
140
  Each formulation keeps its units, validity domain, temperature scale and
115
141
  references. The vocabulary is mapped in [docs/rosetta.md](docs/rosetta.md);
116
142
  [How nomogram earns trust](docs/approach.md) explains what is checked and
117
- how.
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
145
+ 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.
118
148
 
119
149
  ## Contributing
120
150
 
@@ -123,6 +153,26 @@ entries, new formulas and code are all welcome: see the
123
153
  [ways to contribute](.github/CONTRIBUTING.md#ways-to-contribute) and
124
154
  the [catalog guide](docs/catalog.md).
125
155
 
156
+ ## Data sources
157
+
158
+ nomogram's units come from QUDT, and so do its quantity kinds, except
159
+ those QUDT lacks, which nomogram defines as specializations of QUDT
160
+ kinds. nomogram links some kinds to CF standard names, which come, with
161
+ their canonical units, from the CF standard name table. The package
162
+ bundles a subset of each source:
163
+
164
+ - QUDT — Quantities, Units, Dimensions and Types, by QUDT.org
165
+ (<https://qudt.org>), release 3.5.2, licensed under
166
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/);
167
+ - the [CF standard name table](https://cfconventions.org/Data/cf-standard-names/95/build/cf-standard-name-table.html),
168
+ version 95, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/);
169
+ - the UCUM codes of the units, as QUDT gives them: © Regenstrief
170
+ Institute, Inc., [UCUM License](https://ucum.org/license).
171
+
172
+ What nomogram selects, changes and adds is listed in
173
+ [NOTICE.md](src/nomogram/data/NOTICE.md).
174
+
126
175
  ## License
127
176
 
128
- [BSD-3-Clause](LICENSE).
177
+ Code: [BSD-3-Clause](LICENSE). Bundled data: see
178
+ [Data sources](#data-sources).
@@ -0,0 +1,144 @@
1
+ <p align="center">
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">
5
+ </picture>
6
+ </p>
7
+
8
+ **English** | [Français](README.fr.md)
9
+
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
+ [![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
+ [![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)
15
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23373134.svg)](https://doi.org/10.5281/zenodo.23373134)
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
+ [![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)
18
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
19
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-fe5196)](https://www.conventionalcommits.org/en/v1.0.0/)
20
+
21
+ **Formulas as data, not as code.**
22
+
23
+ nomogram does not hard-code functions: it catalogs formulas, each one
24
+
25
+ - **meaningful**: QUDT quantity kinds and units, links to CF and
26
+ Wikidata, its source, its validity domain when stated;
27
+ - **checked**: dimensions and units on loading, equivalences proved
28
+ symbolically, values cross-checked against references;
29
+ - **ready to use**: compute, compose, invert, plot, and compare with its
30
+ alternatives.
31
+
32
+ Built for trust, not for speed: to show that a calculation is right, and
33
+ where it comes from.
34
+
35
+ Pure Python, built on SymPy, numpy and pint; it works on an offline
36
+ workstation (no online engine).
37
+
38
+ > Early stage project (alpha).
39
+
40
+ ## Installation
41
+
42
+ ```
43
+ pip install nomogram # core
44
+ pip install "nomogram[viz]" # with plots (matplotlib)
45
+ ```
46
+
47
+ ## Installation from source
48
+
49
+ ```
50
+ git clone https://github.com/miki4iaml/nomogram.git
51
+ cd nomogram
52
+ uv sync
53
+ ```
54
+
55
+ Python 3.12 or later. Plots need the `viz` extra (matplotlib):
56
+ `uv sync --extra viz`.
57
+
58
+ ## Example
59
+
60
+ Every saturation vapour pressure formulation of the catalog against the
61
+ IAPWS reference, from 0 to 60 °C and within 1 %; curves stop where a formulation leaves its
62
+ stated validity domain, and the figure takes the option set of nomogram's
63
+ documentation ([script](docs/examples/saturation_vapour_pressure.py)):
64
+
65
+ ```python
66
+ import numpy as np
67
+ import nomogram as nomo
68
+ from nomogram.units import Q_
69
+
70
+ catalog = nomo.catalog.catalog()
71
+ formulations = catalog.formulations_of("SaturationVapourPressureOverWater")
72
+ reference = catalog.formulations["iapws_sr1_86"]
73
+ temperatures = Q_(np.linspace(-60, 110, 341), "degC")
74
+
75
+ table = nomo.comparison.compare(
76
+ formulations, reference, {"ThermodynamicTemperature": temperatures}
77
+ )
78
+ with nomo.display.display_options(nomo.display.DisplayOptions.DOCUMENTATION):
79
+ nomo.plot.plot_deviations(table, xlim=(0, 60), ylim=(-1.0, 1.0)) # extra viz
80
+ ```
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)
83
+
84
+ ## Usage
85
+
86
+ The table holds the values and the relative deviations, by formulation;
87
+ values outside a formulation's validity domain are NaN unless asked
88
+ otherwise (`outside="ignore"`). Formulations also chain, for instance
89
+ the absolute humidity from any saturation vapour pressure formulation:
90
+
91
+ ```python
92
+ ha = nomo.comparison.compose(
93
+ catalog.formulations["mass_concentration_ideal_gas"], {"e_s": reference}
94
+ )
95
+ ha.evaluate(RH=Q_(100, "percent"), T=Q_(20, "degC")).to("g/m^3") # 17.29
96
+ ```
97
+
98
+ Formulations are found by words, wildcards and fields, whatever their
99
+ notation ([Finding formulations](docs/search.md)):
100
+
101
+ ```python
102
+ catalog.search(variable="rho*") # ρ in every notation
103
+ catalog.search(kind="VapourPressure", source="buck*")
104
+ ```
105
+
106
+ 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
111
+ 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.
114
+
115
+ ## Contributing
116
+
117
+ References to integrate, information or corrections about catalog
118
+ 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).
121
+
122
+ ## Data sources
123
+
124
+ nomogram's units come from QUDT, and so do its quantity kinds, except
125
+ those QUDT lacks, which nomogram defines as specializations of QUDT
126
+ kinds. nomogram links some kinds to CF standard names, which come, with
127
+ their canonical units, from the CF standard name table. The package
128
+ bundles a subset of each source:
129
+
130
+ - QUDT — Quantities, Units, Dimensions and Types, by QUDT.org
131
+ (<https://qudt.org>), release 3.5.2, licensed under
132
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/);
133
+ - the [CF standard name table](https://cfconventions.org/Data/cf-standard-names/95/build/cf-standard-name-table.html),
134
+ version 95, [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/);
135
+ - the UCUM codes of the units, as QUDT gives them: © Regenstrief
136
+ Institute, Inc., [UCUM License](https://ucum.org/license).
137
+
138
+ What nomogram selects, changes and adds is listed in
139
+ [NOTICE.md](src/nomogram/data/NOTICE.md).
140
+
141
+ ## License
142
+
143
+ Code: [BSD-3-Clause](LICENSE). Bundled data: see
144
+ [Data sources](#data-sources).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -120,6 +120,9 @@ fail_under = 80
120
120
  show_missing = true
121
121
  exclude_also = ['if __name__ == "__main__":']
122
122
 
123
+ [tool.ruff.format]
124
+ exclude = ["*.md"]
125
+
123
126
  [tool.ruff.lint]
124
127
  extend-select = [
125
128
  "ARG",
@@ -138,6 +141,7 @@ extend-select = [
138
141
 
139
142
  [tool.codespell]
140
143
  skip = "*.fr.md,uv.lock"
144
+ ignore-words-list = "flate,alph,mesures"
141
145
 
142
146
  [tool.repo-review.ignore]
143
147
  PC110 = "ruff format runs as a local hook through uv run --frozen (single version source: uv.lock)"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nomogram"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Repository of scientific formulas: notations, units, validity domains"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -102,6 +102,12 @@ fail_under = 80
102
102
  show_missing = true
103
103
  exclude_also = ['if __name__ == "__main__":']
104
104
 
105
+ [tool.ruff.format]
106
+ # ruff also formats the code blocks of Markdown files when run without
107
+ # arguments; the documents keep their own layout (aligned comments), and
108
+ # tests/test_examples.py reads the values their code comments state.
109
+ exclude = ["*.md"]
110
+
105
111
  [tool.ruff.lint]
106
112
  # On top of ruff's default selection, which Dependabot bumps surface in CI.
107
113
  extend-select = ["ARG", "B", "C4", "PIE", "PT", "PTH", "RET", "T20"]
@@ -114,6 +120,9 @@ extend-select = ["ARG", "B", "C4", "PIE", "PT", "PTH", "RET", "T20"]
114
120
 
115
121
  [tool.codespell]
116
122
  skip = "*.fr.md,uv.lock"
123
+ # Format terms: the Flate filter of PDF, the ALPH chunk of WebP; the
124
+ # French name of the BIPM (Bureau International des Poids et Mesures).
125
+ ignore-words-list = "flate,alph,mesures"
117
126
 
118
127
  [tool.repo-review.ignore]
119
128
  PC110 = "ruff format runs as a local hook through uv run --frozen (single version source: uv.lock)"
@@ -0,0 +1,72 @@
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),
10
+ :mod:`nomogram.dimensions`, :mod:`nomogram.comparison`,
11
+ :mod:`nomogram.inversion`, :mod:`nomogram.relations`,
12
+ :mod:`nomogram.search`, :mod:`nomogram.display` and :mod:`nomogram.plot`.
13
+
14
+ Each is also an attribute of the package, imported the first time it is
15
+ used (PEP 562)::
16
+
17
+ import nomogram as nomo
18
+
19
+ formulations = nomo.catalog.catalog().formulations_of("VapourPressure")
20
+ """
21
+
22
+ from importlib import import_module
23
+ from importlib.metadata import version
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from nomogram import catalog as catalog
28
+ from nomogram import comparison as comparison
29
+ from nomogram import dimensions as dimensions
30
+ from nomogram import display as display
31
+ from nomogram import expression as expression
32
+ from nomogram import inversion as inversion
33
+ from nomogram import model as model
34
+ from nomogram import plot as plot
35
+ from nomogram import relations as relations
36
+ from nomogram import search as search
37
+ from nomogram import semantics as semantics
38
+ from nomogram import units as units
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "catalog",
43
+ "comparison",
44
+ "dimensions",
45
+ "display",
46
+ "expression",
47
+ "inversion",
48
+ "model",
49
+ "plot",
50
+ "relations",
51
+ "search",
52
+ "semantics",
53
+ "units",
54
+ ]
55
+
56
+ #: Submodules reachable as attributes of the package.
57
+ _SUBMODULES = frozenset(__all__) - {"__version__"}
58
+
59
+ __version__ = version("nomogram")
60
+
61
+
62
+ def __getattr__(name: str) -> object:
63
+ """Import a submodule the first time it is used as an attribute."""
64
+ if name in _SUBMODULES:
65
+ return import_module(f"{__name__}.{name}")
66
+ msg = f"module {__name__!r} has no attribute {name!r}"
67
+ raise AttributeError(msg)
68
+
69
+
70
+ def __dir__() -> list[str]:
71
+ """The names of the package, submodules included before their import."""
72
+ return sorted({*globals(), *_SUBMODULES})
@@ -0,0 +1,228 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Geometry of the logo, NOMO, shared by its SVG files and the plots.
5
+
6
+ - N is a working alignment chart (an N or Z chart): two parallel scales,
7
+ u read downward on the left stroke and v upward on the right one, and a
8
+ diagonal scale for w = u / v, graduated at s = w / (1 + w) of its
9
+ length. A straight reading line through u and v crosses the diagonal at
10
+ u / v; :func:`n_chart` checks it.
11
+ - The first O is a sphere of linked nodes (shared vocabularies), the
12
+ second a globe in orthographic projection (the Earth sciences).
13
+ - M is plain, and the reading line runs on through O, M and O.
14
+
15
+ Shapes are in SVG units, y downward, with a role that says how each is
16
+ painted (:data:`THEMES`): ``ink`` and ``fine`` strokes (``fine`` at 55 %
17
+ opacity), ``read`` for the reading line, filled ``sea``, ``dot`` and
18
+ ``node``. ``scripts/make_logo.py`` writes them as SVG,
19
+ :func:`nomogram.plot.sign` as matplotlib artists. Standard library only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from dataclasses import dataclass
26
+
27
+ #: Letter height and stroke widths, in SVG units.
28
+ H = 100.0
29
+ STROKE = 8.0
30
+ THIN = 1.6
31
+ TICK = 5.0
32
+
33
+ #: Reading line: u on the left scale, v on the right one (0 to 5).
34
+ U, V = 3.0, 2.2
35
+
36
+ #: Colours: ink, sea of the globe, accent of the reading line.
37
+ THEMES = {
38
+ "light": {"ink": "#1f2937", "sea": "#dbeafe", "accent": "#c2410c"},
39
+ "dark": {"ink": "#e5e7eb", "sea": "#1e3a5f", "accent": "#fb923c"},
40
+ }
41
+
42
+ #: Opacity of the ``fine`` strokes (graduations, graticule, links).
43
+ FINE_OPACITY = 0.55
44
+
45
+ Point = tuple[float, float]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Line:
50
+ start: Point
51
+ end: Point
52
+ role: str
53
+ width: float
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Circle:
58
+ centre: Point
59
+ radius: float
60
+ role: str
61
+ #: Stroke width; 0 for a filled circle.
62
+ width: float = 0.0
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Ellipse:
67
+ centre: Point
68
+ rx: float
69
+ ry: float
70
+ role: str
71
+ width: float
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Polyline:
76
+ points: tuple[Point, ...]
77
+ role: str
78
+ width: float
79
+
80
+
81
+ Shape = Line | Circle | Ellipse | Polyline
82
+
83
+
84
+ def n_chart(
85
+ x: float, y: float, w: float, *, stroke: float = STROKE, ticks: bool = True
86
+ ) -> tuple[list[Shape], Point, Point]:
87
+ """The N as an alignment chart; returns its shapes and the reading points."""
88
+ scale = H / 5.0
89
+ out: list[Shape] = [
90
+ Line((x, y), (x, y + H), "ink", stroke),
91
+ Line((x + w, y), (x + w, y + H), "ink", stroke),
92
+ Line((x, y), (x + w, y + H), "ink", stroke),
93
+ ]
94
+ if ticks:
95
+ # u downward on the left stroke, v upward on the right one.
96
+ for k in range(11):
97
+ value = k / 2.0
98
+ length = TICK * (1.6 if k % 2 == 0 else 1.0)
99
+ left_y, right_y = y + scale * value, y + H - scale * value
100
+ out.append(
101
+ Line(
102
+ (x - length - stroke / 2, left_y),
103
+ (x - stroke / 2, left_y),
104
+ "fine",
105
+ THIN,
106
+ )
107
+ )
108
+ out.append(
109
+ Line(
110
+ (x + w + stroke / 2, right_y),
111
+ (x + w + stroke / 2 + length, right_y),
112
+ "fine",
113
+ THIN,
114
+ )
115
+ )
116
+ # w = u / v on the diagonal, at s = w / (1 + w) from its top end.
117
+ norm = math.hypot(w, H)
118
+ nx, ny = H / norm, -w / norm # unit normal, up and right
119
+ for value in (0.25, 0.5, 1.0, 2.0, 4.0):
120
+ s = value / (1.0 + value)
121
+ cx, cy = x + s * w, y + s * H
122
+ near, far = stroke / 2, stroke / 2 + 1.6 * TICK
123
+ out.append(
124
+ Line(
125
+ (cx + nx * near, cy + ny * near),
126
+ (cx + nx * far, cy + ny * far),
127
+ "fine",
128
+ THIN,
129
+ )
130
+ )
131
+ left = (x, y + scale * U)
132
+ right = (x + w, y + H - scale * V)
133
+ # The reading line crosses the diagonal at w = U / V: check it.
134
+ s = (U / V) / (1.0 + U / V)
135
+ crossing = (left[1] - y) / ((left[1] - y) + (y + H - right[1]))
136
+ if abs(crossing - s) > 1e-12: # pragma: no cover - a broken chart
137
+ msg = f"the reading line crosses the diagonal at {crossing}, not {s}"
138
+ raise AssertionError(msg)
139
+ out.append(Circle((x + s * w, y + s * H), stroke * 0.55, "dot"))
140
+ return out, left, right
141
+
142
+
143
+ def reading_line(
144
+ left: Point, right: Point, start: float, end: float, width: float
145
+ ) -> Line:
146
+ """The straight line through both reading points, from x = start to end."""
147
+ slope = (right[1] - left[1]) / (right[0] - left[0])
148
+ return Line(
149
+ (start, left[1] + slope * (start - left[0])),
150
+ (end, left[1] + slope * (end - left[0])),
151
+ "read",
152
+ width,
153
+ )
154
+
155
+
156
+ def globe(centre: Point, radius: float) -> list[Shape]:
157
+ """A sphere in orthographic projection, seen from the equator."""
158
+ cx, cy = centre
159
+ out: list[Shape] = [Circle(centre, radius, "sea")]
160
+ for longitude in (30.0, 60.0):
161
+ rx = radius * math.sin(math.radians(longitude))
162
+ out.append(Ellipse(centre, rx, radius, "fine", THIN))
163
+ out.append(Line((cx, cy - radius), (cx, cy + radius), "fine", THIN))
164
+ for latitude in (-60.0, -30.0, 0.0, 30.0, 60.0):
165
+ yy = cy - radius * math.sin(math.radians(latitude))
166
+ half = radius * math.cos(math.radians(latitude))
167
+ out.append(Line((cx - half, yy), (cx + half, yy), "fine", THIN))
168
+ out.append(Circle(centre, radius, "ink", STROKE))
169
+ return out
170
+
171
+
172
+ def nodes(centre: Point, radius: float) -> list[Shape]:
173
+ """A sphere of linked nodes: a Fibonacci lattice, its visible side."""
174
+ cx, cy = centre
175
+ count, points = 24, []
176
+ golden = math.pi * (3.0 - math.sqrt(5.0))
177
+ for i in range(count):
178
+ z = 1.0 - 2.0 * (i + 0.5) / count
179
+ ring = math.sqrt(1.0 - z * z)
180
+ x3, y3 = ring * math.cos(golden * i), ring * math.sin(golden * i)
181
+ if x3 > -0.1:
182
+ points.append((cx + 0.82 * radius * y3, cy - 0.82 * radius * z))
183
+ out: list[Shape] = []
184
+ for i, point in enumerate(points):
185
+ nearest = sorted(points[i + 1 :], key=lambda p: math.dist(p, point))[:2]
186
+ out += [
187
+ Line(point, other, "fine", THIN)
188
+ for other in nearest
189
+ if math.dist(point, other) < 0.75 * radius
190
+ ]
191
+ out += [Circle(point, 2.2, "node") for point in points]
192
+ out.append(Circle(centre, radius, "ink", STROKE))
193
+ return out
194
+
195
+
196
+ def m_letter(x: float, y: float, w: float) -> list[Shape]:
197
+ points = ((x, y + H), (x, y), (x + w / 2, y + 0.62 * H), (x + w, y), (x + w, y + H))
198
+ return [Polyline(points, "ink", STROKE)]
199
+
200
+
201
+ def logo() -> tuple[list[Shape], float, float]:
202
+ """The shapes of NOMO, with the width and height they take."""
203
+ margin, width, gap = 22.0, 64.0, 26.0
204
+ x_n = margin
205
+ x_nodes = x_n + width + gap
206
+ x_m = x_nodes + H + gap
207
+ x_globe = x_m + width + gap
208
+ y = margin
209
+ shapes, left, right = n_chart(x_n, y, width)
210
+ shapes += nodes((x_nodes + H / 2, y + H / 2), H / 2)
211
+ shapes += m_letter(x_m, y, width)
212
+ shapes += globe((x_globe + H / 2, y + H / 2), H / 2)
213
+ shapes.append(reading_line(left, right, x_n - 12.0, x_globe + H + 12.0, 2.6))
214
+ shapes += [Circle(point, STROKE * 0.55, "dot") for point in (left, right)]
215
+ return shapes, x_globe + H + margin, H + 2 * margin
216
+
217
+
218
+ def icon(*, small: bool = False) -> tuple[list[Shape], float, float]:
219
+ """The N chart alone; ``small`` drops the graduations (favicon)."""
220
+ margin, width = 26.0, 64.0
221
+ stroke = 14.0 if small else STROKE
222
+ shapes, left, right = n_chart(margin, margin, width, stroke=stroke, ticks=not small)
223
+ line_width = 7.0 if small else 3.2
224
+ shapes.append(
225
+ reading_line(left, right, margin - 14.0, margin + width + 14.0, line_width)
226
+ )
227
+ shapes += [Circle(point, stroke * 0.55, "dot") for point in (left, right)]
228
+ return shapes, width + 2 * margin, H + 2 * margin