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.
@@ -0,0 +1,464 @@
1
+ {
2
+ "cf_standard_names": {
3
+ "air_temperature": {
4
+ "canonical_units": "K",
5
+ "pint": "K"
6
+ },
7
+ "humidity_mixing_ratio": {
8
+ "canonical_units": "1",
9
+ "pint": "dimensionless"
10
+ },
11
+ "mass_concentration_of_water_vapor_in_air": {
12
+ "canonical_units": "kg m-3",
13
+ "pint": "kg * m ** -3"
14
+ },
15
+ "relative_humidity": {
16
+ "canonical_units": "1",
17
+ "pint": "dimensionless"
18
+ },
19
+ "specific_humidity": {
20
+ "canonical_units": "1",
21
+ "pint": "dimensionless"
22
+ },
23
+ "water_vapor_partial_pressure_in_air": {
24
+ "canonical_units": "Pa",
25
+ "pint": "Pa"
26
+ }
27
+ },
28
+ "quantity_kinds": {
29
+ "AbsoluteHumidity": {
30
+ "added_units": [],
31
+ "applicable_units": [
32
+ "GM-PER-M3",
33
+ "KiloGM-PER-M3"
34
+ ],
35
+ "cf_standard_names": [],
36
+ "delta": false,
37
+ "dimension_vector": "A0E0L-3I0M1H0T0D0",
38
+ "iri": "http://qudt.org/vocab/quantitykind/AbsoluteHumidity",
39
+ "label": "Absolute Humidity",
40
+ "matches": [],
41
+ "scale_type": "ratio",
42
+ "specialization_of": "Density"
43
+ },
44
+ "CelsiusTemperature": {
45
+ "added_units": [],
46
+ "applicable_units": [
47
+ "DEG_C",
48
+ "DEG_F"
49
+ ],
50
+ "cf_standard_names": [],
51
+ "delta": false,
52
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
53
+ "iri": "http://qudt.org/vocab/quantitykind/CelsiusTemperature",
54
+ "label": "Celsius Temperature",
55
+ "matches": [
56
+ {
57
+ "relation": "close",
58
+ "target": "http://www.wikidata.org/entity/Q74758530",
59
+ "verified": "2026-10-14"
60
+ }
61
+ ],
62
+ "scale_type": "interval",
63
+ "specialization_of": "Temperature"
64
+ },
65
+ "MassConcentrationOfWaterVapour": {
66
+ "added_units": [],
67
+ "applicable_units": [
68
+ "GM-PER-M3",
69
+ "KiloGM-PER-M3"
70
+ ],
71
+ "cf_standard_names": [
72
+ "mass_concentration_of_water_vapor_in_air"
73
+ ],
74
+ "delta": false,
75
+ "dimension_vector": "A0E0L-3I0M1H0T0D0",
76
+ "iri": "http://qudt.org/vocab/quantitykind/MassConcentrationOfWaterVapour",
77
+ "label": "Mass Concentration of Water Vapour",
78
+ "matches": [
79
+ {
80
+ "relation": "close",
81
+ "target": "http://www.wikidata.org/entity/Q76378808",
82
+ "verified": "2026-10-14"
83
+ }
84
+ ],
85
+ "scale_type": "ratio",
86
+ "specialization_of": "MassConcentration"
87
+ },
88
+ "MassRatioOfWaterVapourToDryGas": {
89
+ "added_units": [
90
+ "GM-PER-KiloGM",
91
+ "KiloGM-PER-KiloGM"
92
+ ],
93
+ "applicable_units": [
94
+ "GM-PER-KiloGM",
95
+ "KiloGM-PER-KiloGM",
96
+ "UNITLESS"
97
+ ],
98
+ "cf_standard_names": [
99
+ "humidity_mixing_ratio"
100
+ ],
101
+ "delta": false,
102
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
103
+ "iri": "http://qudt.org/vocab/quantitykind/MassRatioOfWaterVapourToDryGas",
104
+ "label": "Mass Ratio of Water Vapour to Dry Gas",
105
+ "matches": [
106
+ {
107
+ "relation": "close",
108
+ "target": "http://www.wikidata.org/entity/Q17232415",
109
+ "verified": "2026-10-14"
110
+ }
111
+ ],
112
+ "scale_type": "ratio",
113
+ "specialization_of": null
114
+ },
115
+ "RelativeHumidity": {
116
+ "added_units": [
117
+ "UNITLESS"
118
+ ],
119
+ "applicable_units": [
120
+ "PERCENT",
121
+ "UNITLESS"
122
+ ],
123
+ "cf_standard_names": [
124
+ "relative_humidity"
125
+ ],
126
+ "delta": false,
127
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
128
+ "iri": "http://qudt.org/vocab/quantitykind/RelativeHumidity",
129
+ "label": "Relative Humidity",
130
+ "matches": [
131
+ {
132
+ "relation": "close",
133
+ "target": "http://www.wikidata.org/entity/Q2499617",
134
+ "verified": "2026-10-14"
135
+ }
136
+ ],
137
+ "scale_type": "ratio",
138
+ "specialization_of": "RelativePartialPressure"
139
+ },
140
+ "SpecificHumidity": {
141
+ "added_units": [
142
+ "UNITLESS"
143
+ ],
144
+ "applicable_units": [
145
+ "GM-PER-KiloGM",
146
+ "KiloGM-PER-KiloGM",
147
+ "UNITLESS"
148
+ ],
149
+ "cf_standard_names": [
150
+ "specific_humidity"
151
+ ],
152
+ "delta": false,
153
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
154
+ "iri": "http://qudt.org/vocab/quantitykind/SpecificHumidity",
155
+ "label": "Specific Humidity",
156
+ "matches": [],
157
+ "scale_type": "ratio",
158
+ "specialization_of": "MassRatio"
159
+ },
160
+ "TemperatureDifference": {
161
+ "added_units": [],
162
+ "applicable_units": [
163
+ "K"
164
+ ],
165
+ "cf_standard_names": [],
166
+ "delta": true,
167
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
168
+ "iri": "http://qudt.org/vocab/quantitykind/TemperatureDifference",
169
+ "label": "Temperature Difference",
170
+ "matches": [],
171
+ "scale_type": "ratio",
172
+ "specialization_of": "Temperature"
173
+ },
174
+ "ThermodynamicTemperature": {
175
+ "added_units": [],
176
+ "applicable_units": [
177
+ "K"
178
+ ],
179
+ "cf_standard_names": [
180
+ "air_temperature"
181
+ ],
182
+ "delta": false,
183
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
184
+ "iri": "http://qudt.org/vocab/quantitykind/ThermodynamicTemperature",
185
+ "label": "Thermodynamic Temperature",
186
+ "matches": [
187
+ {
188
+ "relation": "close",
189
+ "target": "http://www.wikidata.org/entity/Q264647",
190
+ "verified": "2026-10-14"
191
+ }
192
+ ],
193
+ "scale_type": "ratio",
194
+ "specialization_of": "Temperature"
195
+ },
196
+ "VapourPressure": {
197
+ "added_units": [],
198
+ "applicable_units": [
199
+ "HectoPA",
200
+ "KiloPA",
201
+ "PA"
202
+ ],
203
+ "cf_standard_names": [
204
+ "water_vapor_partial_pressure_in_air"
205
+ ],
206
+ "delta": false,
207
+ "dimension_vector": "A0E0L-1I0M1H0T-2D0",
208
+ "iri": "http://qudt.org/vocab/quantitykind/VapourPressure",
209
+ "label": "Vapour Pressure",
210
+ "matches": [],
211
+ "scale_type": "ratio",
212
+ "specialization_of": "Pressure"
213
+ }
214
+ },
215
+ "sources": {
216
+ "cf_standard_names": {
217
+ "attribution": "CF Conventions, https://cfconventions.org/",
218
+ "license": "CC0-1.0",
219
+ "version": "95"
220
+ },
221
+ "extracted": "2026-10-14",
222
+ "qudt": {
223
+ "attribution": "QUDT.org, https://qudt.org/",
224
+ "license": "CC-BY-4.0",
225
+ "version": "3.5.2"
226
+ }
227
+ },
228
+ "units": {
229
+ "DEG_C": {
230
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
231
+ "iri": "http://qudt.org/vocab/unit/DEG_C",
232
+ "label": "Degree Celsius",
233
+ "matches": [
234
+ {
235
+ "relation": "close",
236
+ "target": "http://www.wikidata.org/entity/Q25267",
237
+ "verified": "2026-10-14"
238
+ }
239
+ ],
240
+ "multiplier": 1.0,
241
+ "offset": 273.15,
242
+ "pint": "degC",
243
+ "symbol": "\u00b0C",
244
+ "ucum": "Cel",
245
+ "udunits": "\u00b0C"
246
+ },
247
+ "DEG_F": {
248
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
249
+ "iri": "http://qudt.org/vocab/unit/DEG_F",
250
+ "label": "Degree Fahrenheit",
251
+ "matches": [
252
+ {
253
+ "relation": "close",
254
+ "target": "http://www.wikidata.org/entity/Q42289",
255
+ "verified": "2026-10-14"
256
+ }
257
+ ],
258
+ "multiplier": 0.5555555555555556,
259
+ "offset": 459.67,
260
+ "pint": "degF",
261
+ "symbol": "\u00b0F",
262
+ "ucum": "[degF]",
263
+ "udunits": "\u00b0F"
264
+ },
265
+ "GM-PER-KiloGM": {
266
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
267
+ "iri": "http://qudt.org/vocab/unit/GM-PER-KiloGM",
268
+ "label": "Gram per Kilogram",
269
+ "matches": [
270
+ {
271
+ "relation": "close",
272
+ "target": "http://www.wikidata.org/entity/Q21061369",
273
+ "verified": "2026-10-14"
274
+ }
275
+ ],
276
+ "multiplier": 0.001,
277
+ "offset": 0.0,
278
+ "pint": "g/kg",
279
+ "symbol": "g/kg",
280
+ "ucum": "g.kg-1",
281
+ "udunits": null
282
+ },
283
+ "GM-PER-M3": {
284
+ "dimension_vector": "A0E0L-3I0M1H0T0D0",
285
+ "iri": "http://qudt.org/vocab/unit/GM-PER-M3",
286
+ "label": "Gram per Cubic Metre",
287
+ "matches": [
288
+ {
289
+ "relation": "close",
290
+ "target": "http://www.wikidata.org/entity/Q21604951",
291
+ "verified": "2026-10-14"
292
+ }
293
+ ],
294
+ "multiplier": 0.001,
295
+ "offset": 0.0,
296
+ "pint": "g/m^3",
297
+ "symbol": "g/m\u00b3",
298
+ "ucum": "g.m-3",
299
+ "udunits": null
300
+ },
301
+ "HectoPA": {
302
+ "dimension_vector": "A0E0L-1I0M1H0T-2D0",
303
+ "iri": "http://qudt.org/vocab/unit/HectoPA",
304
+ "label": "Hectopascal",
305
+ "matches": [
306
+ {
307
+ "relation": "close",
308
+ "target": "http://www.wikidata.org/entity/Q5139563",
309
+ "verified": "2026-10-14"
310
+ }
311
+ ],
312
+ "multiplier": 100.0,
313
+ "offset": 0.0,
314
+ "pint": "hPa",
315
+ "symbol": "hPa",
316
+ "ucum": "hPa",
317
+ "udunits": null
318
+ },
319
+ "J-PER-KiloGM-K": {
320
+ "dimension_vector": "A0E0L2I0M0H-1T-2D0",
321
+ "iri": "http://qudt.org/vocab/unit/J-PER-KiloGM-K",
322
+ "label": "Joule per Kilogram Kelvin",
323
+ "matches": [
324
+ {
325
+ "relation": "close",
326
+ "target": "http://www.wikidata.org/entity/Q3085309",
327
+ "verified": "2026-10-14"
328
+ }
329
+ ],
330
+ "multiplier": 1.0,
331
+ "offset": 0.0,
332
+ "pint": "J/(kg*K)",
333
+ "symbol": "J/(kg\u00b7K)",
334
+ "ucum": "J.kg-1.K-1",
335
+ "udunits": null
336
+ },
337
+ "K": {
338
+ "dimension_vector": "A0E0L0I0M0H1T0D0",
339
+ "iri": "http://qudt.org/vocab/unit/K",
340
+ "label": "Kelvin",
341
+ "matches": [
342
+ {
343
+ "relation": "close",
344
+ "target": "http://www.wikidata.org/entity/Q11579",
345
+ "verified": "2026-10-14"
346
+ }
347
+ ],
348
+ "multiplier": 1.0,
349
+ "offset": 0.0,
350
+ "pint": "K",
351
+ "symbol": "K",
352
+ "ucum": "K",
353
+ "udunits": "K"
354
+ },
355
+ "KiloGM-PER-KiloGM": {
356
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
357
+ "iri": "http://qudt.org/vocab/unit/KiloGM-PER-KiloGM",
358
+ "label": "Kilogram per Kilogram",
359
+ "matches": [
360
+ {
361
+ "relation": "close",
362
+ "target": "http://www.wikidata.org/entity/Q106645216",
363
+ "verified": "2026-10-14"
364
+ }
365
+ ],
366
+ "multiplier": 1.0,
367
+ "offset": 0.0,
368
+ "pint": "kg/kg",
369
+ "symbol": "kg/kg",
370
+ "ucum": "kg.kg-1",
371
+ "udunits": null
372
+ },
373
+ "KiloGM-PER-M3": {
374
+ "dimension_vector": "A0E0L-3I0M1H0T0D0",
375
+ "iri": "http://qudt.org/vocab/unit/KiloGM-PER-M3",
376
+ "label": "Kilogram per Cubic Metre",
377
+ "matches": [
378
+ {
379
+ "relation": "close",
380
+ "target": "http://www.wikidata.org/entity/Q844211",
381
+ "verified": "2026-10-14"
382
+ }
383
+ ],
384
+ "multiplier": 1.0,
385
+ "offset": 0.0,
386
+ "pint": "kg/m^3",
387
+ "symbol": "kg/m\u00b3",
388
+ "ucum": "kg.m-3",
389
+ "udunits": null
390
+ },
391
+ "KiloPA": {
392
+ "dimension_vector": "A0E0L-1I0M1H0T-2D0",
393
+ "iri": "http://qudt.org/vocab/unit/KiloPA",
394
+ "label": "Kilopascal",
395
+ "matches": [
396
+ {
397
+ "relation": "close",
398
+ "target": "http://www.wikidata.org/entity/Q21064807",
399
+ "verified": "2026-10-14"
400
+ }
401
+ ],
402
+ "multiplier": 1000.0,
403
+ "offset": 0.0,
404
+ "pint": "kPa",
405
+ "symbol": "kPa",
406
+ "ucum": "kPa",
407
+ "udunits": null
408
+ },
409
+ "PA": {
410
+ "dimension_vector": "A0E0L-1I0M1H0T-2D0",
411
+ "iri": "http://qudt.org/vocab/unit/PA",
412
+ "label": "Pascal",
413
+ "matches": [
414
+ {
415
+ "relation": "close",
416
+ "target": "http://www.wikidata.org/entity/Q44395",
417
+ "verified": "2026-10-14"
418
+ }
419
+ ],
420
+ "multiplier": 1.0,
421
+ "offset": 0.0,
422
+ "pint": "Pa",
423
+ "symbol": "Pa",
424
+ "ucum": "Pa",
425
+ "udunits": "Pa"
426
+ },
427
+ "PERCENT": {
428
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
429
+ "iri": "http://qudt.org/vocab/unit/PERCENT",
430
+ "label": "Percent",
431
+ "matches": [
432
+ {
433
+ "relation": "close",
434
+ "target": "http://www.wikidata.org/entity/Q11229",
435
+ "verified": "2026-10-14"
436
+ }
437
+ ],
438
+ "multiplier": 0.01,
439
+ "offset": 0.0,
440
+ "pint": "percent",
441
+ "symbol": "%",
442
+ "ucum": "%",
443
+ "udunits": "%"
444
+ },
445
+ "UNITLESS": {
446
+ "dimension_vector": "A0E0L0I0M0H0T0D1",
447
+ "iri": "http://qudt.org/vocab/unit/UNITLESS",
448
+ "label": "Unitless",
449
+ "matches": [
450
+ {
451
+ "relation": "close",
452
+ "target": "http://www.wikidata.org/entity/Q199",
453
+ "verified": "2026-10-14"
454
+ }
455
+ ],
456
+ "multiplier": 1.0,
457
+ "offset": 0.0,
458
+ "pint": "dimensionless",
459
+ "symbol": "\u4e00",
460
+ "ucum": null,
461
+ "udunits": null
462
+ }
463
+ }
464
+ }
nomogram/expression.py ADDED
@@ -0,0 +1,232 @@
1
+ # SPDX-FileCopyrightText: 2026 Miki
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Formula expressions: safe parsing, LaTeX rendering and numeric evaluation.
5
+
6
+ A formula's mathematical content is written once, in a restricted Python
7
+ syntax such as ``C * exp(A * t / (B + t))``. That text is the single
8
+ source of truth: it is parsed with :mod:`ast` against a whitelist and
9
+ turned directly into a SymPy expression, without ever calling
10
+ :func:`eval` (``sympy.parse_expr`` relies on ``eval`` and is not meant for
11
+ untrusted input). Everything else derives from the SymPy expression:
12
+
13
+ - LaTeX, rendered in any author's notation;
14
+ - numeric evaluation, vectorised with numpy through ``lambdify``;
15
+ - symbolic computation.
16
+
17
+ Names in an expression are semantic *identifiers* (``t``, ``RH``,
18
+ ``e_s``...), each becoming a SymPy symbol of that name. They are never
19
+ glyphs: the symbol a reader sees (QUDT's ``symbol``/``latexSymbol``)
20
+ belongs to a notation. See ``docs/rosetta.md`` for the vocabulary.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ from collections.abc import Callable, Iterable, Mapping
27
+ from functools import lru_cache
28
+ from typing import Any
29
+
30
+ import sympy
31
+ from sympy.codegen.cfunctions import log10
32
+ from sympy.printing.latex import LatexPrinter
33
+
34
+ __all__ = [
35
+ "FUNCTIONS",
36
+ "MAX_SOURCE_LENGTH",
37
+ "ExpressionError",
38
+ "compile_numeric",
39
+ "parse_expression",
40
+ "to_latex",
41
+ ]
42
+
43
+ #: Functions allowed in a formula, by the name used in the source text.
44
+ FUNCTIONS: Mapping[str, Callable[[Any], Any]] = {
45
+ "exp": sympy.exp,
46
+ "ln": sympy.log,
47
+ "log10": log10,
48
+ "sqrt": sympy.sqrt,
49
+ }
50
+
51
+ #: Longest accepted source text, to bound parsing work.
52
+ MAX_SOURCE_LENGTH = 2000
53
+
54
+ _BINARY_OPERATORS: Mapping[type[ast.operator], Callable[[Any, Any], Any]] = {
55
+ ast.Add: lambda a, b: a + b,
56
+ ast.Sub: lambda a, b: a - b,
57
+ ast.Mult: lambda a, b: a * b,
58
+ ast.Div: lambda a, b: a / b,
59
+ ast.Pow: lambda a, b: a**b,
60
+ }
61
+
62
+ _UNARY_OPERATORS: Mapping[type[ast.unaryop], Callable[[Any], Any]] = {
63
+ ast.USub: lambda a: -a,
64
+ ast.UAdd: lambda a: a,
65
+ }
66
+
67
+
68
+ class ExpressionError(ValueError):
69
+ """The source text is not a valid formula expression."""
70
+
71
+
72
+ def parse_expression(source: str, names: Iterable[str]) -> Any:
73
+ """Parse a formula written in restricted Python syntax into a SymPy expression.
74
+
75
+ Allowed: numbers, the declared names, ``+ - * / **``, unary ``-`` and
76
+ ``+``, parentheses, and calls to the functions of :data:`FUNCTIONS`
77
+ with exactly one positional argument. Everything else (attributes,
78
+ subscripts, keywords, lambdas, comparisons, strings, other calls,
79
+ undeclared names) is rejected. Nothing is evaluated.
80
+
81
+ Parameters
82
+ ----------
83
+ source : str
84
+ The expression, for example ``"C * exp(A * t / (B + t))"``.
85
+ names : iterable of str
86
+ The identifiers the expression may use (variables, coefficients
87
+ and constants). Each becomes a real SymPy symbol with that name.
88
+
89
+ Returns
90
+ -------
91
+ sympy.Expr
92
+ The parsed expression.
93
+
94
+ Raises
95
+ ------
96
+ ExpressionError
97
+ If the text is too long, is not valid Python syntax, or uses
98
+ anything outside the whitelist.
99
+ """
100
+ if len(source) > MAX_SOURCE_LENGTH:
101
+ msg = f"expression longer than {MAX_SOURCE_LENGTH} characters"
102
+ raise ExpressionError(msg)
103
+ allowed = frozenset(names)
104
+ for name in allowed:
105
+ if not name.isidentifier() or name in FUNCTIONS:
106
+ msg = f"invalid or reserved name: {name!r}"
107
+ raise ExpressionError(msg)
108
+ try:
109
+ tree = ast.parse(source.strip(), mode="eval")
110
+ except SyntaxError as exc:
111
+ msg = f"invalid syntax: {exc.msg}"
112
+ raise ExpressionError(msg) from None
113
+ symbols = {name: sympy.Symbol(name, real=True) for name in allowed}
114
+ return _build(tree.body, symbols)
115
+
116
+
117
+ def _build(node: ast.AST, symbols: Mapping[str, Any]) -> Any:
118
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS:
119
+ left, right = _build(node.left, symbols), _build(node.right, symbols)
120
+ return _BINARY_OPERATORS[type(node.op)](left, right)
121
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS:
122
+ return _UNARY_OPERATORS[type(node.op)](_build(node.operand, symbols))
123
+ if isinstance(node, ast.Constant):
124
+ value = node.value
125
+ # bool is a subclass of int: refuse it explicitly.
126
+ if isinstance(value, bool) or not isinstance(value, int | float):
127
+ msg = f"unsupported constant: {value!r}"
128
+ raise ExpressionError(msg)
129
+ return (
130
+ sympy.Integer(value) if isinstance(value, int) else sympy.Float(repr(value))
131
+ )
132
+ if isinstance(node, ast.Name):
133
+ if node.id not in symbols:
134
+ msg = f"undeclared name: {node.id!r}"
135
+ raise ExpressionError(msg)
136
+ return symbols[node.id]
137
+ if isinstance(node, ast.Call):
138
+ if not isinstance(node.func, ast.Name) or node.func.id not in FUNCTIONS:
139
+ msg = "only calls to " + ", ".join(sorted(FUNCTIONS)) + " are allowed"
140
+ raise ExpressionError(msg)
141
+ if node.keywords or len(node.args) != 1:
142
+ msg = f"{node.func.id}() takes exactly one positional argument"
143
+ raise ExpressionError(msg)
144
+ return FUNCTIONS[node.func.id](_build(node.args[0], symbols))
145
+ msg = f"unsupported syntax: {type(node).__name__}"
146
+ raise ExpressionError(msg)
147
+
148
+
149
+ class _NotationPrinter(LatexPrinter): # type: ignore[misc]
150
+ """LaTeX printer for formulas.
151
+
152
+ The exponential is written ``\\exp\\left(...\\right)`` rather than
153
+ ``e^{...}``: in meteorology ``e`` denotes the vapour pressure. The
154
+ decimal logarithm is written ``\\log_{10}``, as in the sources.
155
+ """
156
+
157
+ def _print_ExpBase(self, expr: Any, exp: Any = None) -> str:
158
+ tex = r"\exp\left(" + self._print(expr.args[0]) + r"\right)"
159
+ return f"{tex}^{{{exp}}}" if exp is not None else tex
160
+
161
+ def _print_log10(self, expr: Any, exp: Any = None) -> str:
162
+ tex = r"\log_{10}\left(" + self._print(expr.args[0]) + r"\right)"
163
+ return f"{tex}^{{{exp}}}" if exp is not None else tex
164
+
165
+
166
+ def to_latex(expr: Any, notation: Mapping[str, str] | None = None) -> str:
167
+ """Render an expression in LaTeX, in a given notation.
168
+
169
+ The output stays within what both matplotlib's mathtext and MathJax
170
+ render (``\\frac``, ``\\exp``, ``\\ln``, ``\\left``/``\\right``, braced
171
+ subscripts), so that one string serves documentation and plot labels.
172
+
173
+ Parameters
174
+ ----------
175
+ expr : sympy.Expr
176
+ An expression returned by :func:`parse_expression`.
177
+ notation : mapping of str to str, optional
178
+ LaTeX symbol for each identifier, for example
179
+ ``{"e_s": r"E_{\\mathrm{w}}"}``. Identifiers not listed keep
180
+ SymPy's default rendering.
181
+
182
+ Returns
183
+ -------
184
+ str
185
+ The LaTeX code, without surrounding dollar signs.
186
+ """
187
+ names = {}
188
+ for symbol in expr.free_symbols:
189
+ if notation and symbol.name in notation:
190
+ names[symbol] = notation[symbol.name]
191
+ printer = _NotationPrinter({"symbol_names": names, "ln_notation": True})
192
+ return str(printer.doprint(expr))
193
+
194
+
195
+ def compile_numeric(expr: Any, arguments: tuple[str, ...]) -> Callable[..., Any]:
196
+ """Compile an expression into a numeric function, vectorised with numpy.
197
+
198
+ The compiled function is cached: compiling a SymPy expression is costly
199
+ and deterministic.
200
+
201
+ Parameters
202
+ ----------
203
+ expr : sympy.Expr
204
+ An expression returned by :func:`parse_expression`.
205
+ arguments : tuple of str
206
+ Identifiers of the function's positional arguments, in order.
207
+ Every identifier used by the expression must be listed.
208
+
209
+ Returns
210
+ -------
211
+ callable
212
+ A function of the given arguments, accepting numbers or numpy
213
+ arrays (broadcast together).
214
+
215
+ Raises
216
+ ------
217
+ ExpressionError
218
+ If an identifier used by the expression is not among the
219
+ arguments.
220
+ """
221
+ missing = sorted(s.name for s in expr.free_symbols if s.name not in arguments)
222
+ if missing:
223
+ msg = "missing arguments: " + ", ".join(missing)
224
+ raise ExpressionError(msg)
225
+ return _lambdify(expr, arguments)
226
+
227
+
228
+ @lru_cache(maxsize=256)
229
+ def _lambdify(expr: Any, arguments: tuple[str, ...]) -> Callable[..., Any]:
230
+ symbols = [sympy.Symbol(name, real=True) for name in arguments]
231
+ function: Callable[..., Any] = sympy.lambdify(symbols, expr, modules="numpy")
232
+ return function