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