triplot 1.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.
- dscpanel/__init__.py +4 -0
- dscpanel/__main__.py +155 -0
- dscpanel/branding.py +142 -0
- dscpanel/core/__init__.py +0 -0
- dscpanel/core/arrange.py +143 -0
- dscpanel/core/chem.py +252 -0
- dscpanel/core/dtg.py +135 -0
- dscpanel/core/export.py +649 -0
- dscpanel/core/figure.py +171 -0
- dscpanel/core/labels.py +430 -0
- dscpanel/core/loader.py +202 -0
- dscpanel/core/log.py +147 -0
- dscpanel/core/measure.py +636 -0
- dscpanel/core/model.py +1993 -0
- dscpanel/core/molar.py +344 -0
- dscpanel/core/numbers.py +208 -0
- dscpanel/core/ops.py +161 -0
- dscpanel/core/presets.py +312 -0
- dscpanel/core/profile.py +15 -0
- dscpanel/core/session.py +667 -0
- dscpanel/core/shades.py +54 -0
- dscpanel/core/style.py +528 -0
- dscpanel/core/trios_analysis.py +636 -0
- dscpanel/core/trios_io.py +1311 -0
- dscpanel/core/undo.py +230 -0
- dscpanel/core/units.py +220 -0
- dscpanel/register.py +284 -0
- dscpanel/ui/__init__.py +0 -0
- dscpanel/ui/appearance.py +146 -0
- dscpanel/ui/colour.py +629 -0
- dscpanel/ui/dialogs.py +3639 -0
- dscpanel/ui/loading.py +95 -0
- dscpanel/ui/numbox.py +103 -0
- dscpanel/ui/outliner.py +818 -0
- dscpanel/ui/palette.py +193 -0
- dscpanel/ui/plot.py +8349 -0
- dscpanel/ui/settings.py +256 -0
- dscpanel/ui/window.py +4129 -0
- triplot-1.1.0.dist-info/METADATA +315 -0
- triplot-1.1.0.dist-info/RECORD +44 -0
- triplot-1.1.0.dist-info/WHEEL +5 -0
- triplot-1.1.0.dist-info/entry_points.txt +5 -0
- triplot-1.1.0.dist-info/licenses/LICENSE +22 -0
- triplot-1.1.0.dist-info/top_level.txt +1 -0
dscpanel/core/molar.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Molar masses from what a chemist types: a sum formula, a SMILES, or a
|
|
2
|
+
composition of building blocks.
|
|
3
|
+
|
|
4
|
+
A small calculator beside the molar mass. Three readings:
|
|
5
|
+
|
|
6
|
+
* **a sum formula**: element symbols and counts, brackets with a count
|
|
7
|
+
after them, decimals, and adducts after `*` (or the
|
|
8
|
+
middle dot; a "." is a decimal point): `C6H6`, `Zn(C3H3N2)2`,
|
|
9
|
+
`C14.2H12.6N4O2Zn`, `CuSO4*5H2O`;
|
|
10
|
+
* **a SMILES**: hydrogens added, as RDKit counts them: `O=C(O)c1ccccc1`;
|
|
11
|
+
* **a composition**: components `(key)coefficient` or `key[coefficient]`,
|
|
12
|
+
joined by `+` or nothing, where a key is a building block of
|
|
13
|
+
`BUILDING_BLOCKS` - or any sum formula or SMILES written in the
|
|
14
|
+
brackets: `Zn(im)1.70(bim)0.30`.
|
|
15
|
+
|
|
16
|
+
`calculate(text)` reads it the way that fits (`read` says which) or the
|
|
17
|
+
way asked for. The atomic weights are RDKit's. RDKit is a dependency;
|
|
18
|
+
without it the calculator says so rather than guessing.
|
|
19
|
+
|
|
20
|
+
UI-free.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from collections import OrderedDict
|
|
25
|
+
|
|
26
|
+
#: The building blocks of a composition: a key, as it is typed, and its
|
|
27
|
+
#: SMILES.
|
|
28
|
+
BUILDING_BLOCKS = OrderedDict((
|
|
29
|
+
("im", "C1=NC=C[N-]1"),
|
|
30
|
+
("imH", "C1=NC=CN1"),
|
|
31
|
+
("Clim", "ClC1=C[N-]C=N1"),
|
|
32
|
+
("Brim", "BrC1=C[N-]C=N1"),
|
|
33
|
+
("Iim", "IC1=C[N-]C=N1"),
|
|
34
|
+
("Fim", "FC1=C[N-]C=N1"),
|
|
35
|
+
("dClim", "ClC1=C(Cl)[N-]C=N1"),
|
|
36
|
+
("mim", "CC1=C[N-]C=N1"),
|
|
37
|
+
("mimH", "CC1=CNC=N1"),
|
|
38
|
+
("mbim", "CC1=CC=C2C(N=C[N-]2)=C1"),
|
|
39
|
+
("CNim", "N#CC1=C[N-]C=N1"),
|
|
40
|
+
("dCNim", "N#CC1=C(C#N)N=C[N-]1"),
|
|
41
|
+
("bim", "C12=CC=CC=C1[N-]C=N2"),
|
|
42
|
+
("Zn", "[Zn++]"),
|
|
43
|
+
("Co", "[Co++]"),
|
|
44
|
+
("bc", "C1=CC=CC=C1C(=O)[O-]"),
|
|
45
|
+
("Hbc", "C1=CC=CC=C1C(=O)O"),
|
|
46
|
+
("H2bdc", "O=C(O)C1=CC=C(C(O)=O)C=C1"),
|
|
47
|
+
("bdc", "O=C([O-])C1=CC=C(C([O-])=O)C=C1"),
|
|
48
|
+
("Hpc", "O=C(O)C1=NC=CC=C1"),
|
|
49
|
+
("pc", "O=C([O-])C1=NC=CC=C1"),
|
|
50
|
+
("H2pPDA", "O=C(O)CC1=CC=C(CC(O)=O)C=C1"),
|
|
51
|
+
("HoPyBz", "O=C(O)C(C=C1)=CC=C1C2=CC=CN=C2"),
|
|
52
|
+
("HpPyBz", "O=C(O)C(C=C1)=CC=C1C2=CC=NC=C2"),
|
|
53
|
+
("H2adp", "OC(CCCCC(O)=O)=O"),
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
AUTO = "auto"
|
|
57
|
+
FORMULA = "formula"
|
|
58
|
+
SMILES = "smiles"
|
|
59
|
+
COMPOSITION = "composition"
|
|
60
|
+
READINGS = (AUTO, FORMULA, SMILES, COMPOSITION)
|
|
61
|
+
TITLES = {AUTO: "whichever fits", FORMULA: "a sum formula",
|
|
62
|
+
SMILES: "a SMILES", COMPOSITION: "a composition"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Result(object):
|
|
66
|
+
"""What `calculate` found: how it read the text, the atom counts
|
|
67
|
+
(symbol -> count, possibly fractional), the Hill formula, the molar mass
|
|
68
|
+
in g/mol - or `error` and no mass."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, read=None, counts=None, error=None):
|
|
71
|
+
self.read = read
|
|
72
|
+
self.counts = dict(counts or {})
|
|
73
|
+
self.error = error
|
|
74
|
+
self.mass = None
|
|
75
|
+
self.formula = ""
|
|
76
|
+
if counts and error is None:
|
|
77
|
+
try:
|
|
78
|
+
self.mass = mass_of(self.counts)
|
|
79
|
+
self.formula = hill(self.counts)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
self.error = str(exc)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def ok(self):
|
|
85
|
+
return self.error is None and self.mass is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _table():
|
|
89
|
+
from rdkit import Chem
|
|
90
|
+
return Chem.GetPeriodicTable()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def available():
|
|
94
|
+
try:
|
|
95
|
+
_table()
|
|
96
|
+
except ImportError:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_SYMBOLS = []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_element(symbol):
|
|
105
|
+
"""True for an element symbol - looked up in a set made once, since
|
|
106
|
+
RDKit asked about a key such as "bim" prints a C++ complaint to the
|
|
107
|
+
console."""
|
|
108
|
+
if not _SYMBOLS:
|
|
109
|
+
table = _table()
|
|
110
|
+
_SYMBOLS.append(frozenset(table.GetElementSymbol(z)
|
|
111
|
+
for z in range(1, 119)))
|
|
112
|
+
return symbol in _SYMBOLS[0]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mass_of(counts):
|
|
116
|
+
"""g/mol of `counts` (symbol -> count), RDKit's atomic weights."""
|
|
117
|
+
table = _table()
|
|
118
|
+
total = 0.0
|
|
119
|
+
for symbol, count in counts.items():
|
|
120
|
+
if not _is_element(symbol):
|
|
121
|
+
raise ValueError("{} is not an element".format(symbol))
|
|
122
|
+
total += table.GetAtomicWeight(symbol) * float(count)
|
|
123
|
+
return total
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def hill(counts, decimals=3):
|
|
127
|
+
"""The Hill formula: C, then H, then the rest alphabetically (all
|
|
128
|
+
alphabetically without carbon); fractional counts to `decimals`."""
|
|
129
|
+
def written(count):
|
|
130
|
+
count = round(float(count), decimals)
|
|
131
|
+
if count == int(count):
|
|
132
|
+
count = int(count)
|
|
133
|
+
return "" if count == 1 else "%g" % count
|
|
134
|
+
|
|
135
|
+
present = dict((s, c) for s, c in counts.items() if abs(c) > 1e-12)
|
|
136
|
+
if "C" in present:
|
|
137
|
+
order = ["C"] + (["H"] if "H" in present else [])
|
|
138
|
+
order += sorted(s for s in present if s not in ("C", "H"))
|
|
139
|
+
else:
|
|
140
|
+
order = sorted(present)
|
|
141
|
+
return "".join(s + written(present[s]) for s in order)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------- sum formulas
|
|
145
|
+
_NUMBER = r"(?:\d+\.\d*|\.\d+|\d+)"
|
|
146
|
+
_FORMULA_TOKEN = re.compile(r"([A-Z][a-z]?)(" + _NUMBER + r")?"
|
|
147
|
+
r"|([(\[])|([)\]])(" + _NUMBER + r")?")
|
|
148
|
+
_ADDUCT = re.compile(r"[*\u00b7]")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_formula(text):
|
|
152
|
+
"""A sum formula as `{symbol: count}`, or ValueError. Brackets carry
|
|
153
|
+
a count after them; `*` or a middle dot starts an adduct with its own
|
|
154
|
+
leading count (`CuSO4*5H2O`). A "." is a decimal point, never an
|
|
155
|
+
adduct: `C14.2H12.6N4O2Zn`, a composition's fractional formula."""
|
|
156
|
+
text = str(text or "").strip().replace(" ", "")
|
|
157
|
+
if not text:
|
|
158
|
+
raise ValueError("nothing to read")
|
|
159
|
+
total = {}
|
|
160
|
+
for part in _ADDUCT.split(text):
|
|
161
|
+
if not part:
|
|
162
|
+
raise ValueError("an empty part after a dot")
|
|
163
|
+
lead = re.match(_NUMBER, part)
|
|
164
|
+
factor = 1.0
|
|
165
|
+
if lead and lead.end() < len(part):
|
|
166
|
+
factor = float(lead.group(0))
|
|
167
|
+
part = part[lead.end():]
|
|
168
|
+
for symbol, count in _formula_part(part).items():
|
|
169
|
+
total[symbol] = total.get(symbol, 0.0) + count * factor
|
|
170
|
+
return total
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _formula_part(text):
|
|
174
|
+
stack = [{}]
|
|
175
|
+
pos = 0
|
|
176
|
+
for match in _FORMULA_TOKEN.finditer(text):
|
|
177
|
+
if match.start() != pos:
|
|
178
|
+
raise ValueError("cannot read {!r}".format(text[pos:]))
|
|
179
|
+
pos = match.end()
|
|
180
|
+
symbol, count, opening, closing, times = match.groups()
|
|
181
|
+
if symbol:
|
|
182
|
+
if not _is_element(symbol):
|
|
183
|
+
raise ValueError("{} is not an element".format(symbol))
|
|
184
|
+
group = stack[-1]
|
|
185
|
+
group[symbol] = group.get(symbol, 0.0) + (
|
|
186
|
+
float(count) if count else 1.0)
|
|
187
|
+
elif opening:
|
|
188
|
+
stack.append({})
|
|
189
|
+
else:
|
|
190
|
+
if len(stack) < 2:
|
|
191
|
+
raise ValueError("a bracket closed that was never opened")
|
|
192
|
+
inner = stack.pop()
|
|
193
|
+
factor = float(times) if times else 1.0
|
|
194
|
+
outer = stack[-1]
|
|
195
|
+
for key, value in inner.items():
|
|
196
|
+
outer[key] = outer.get(key, 0.0) + value * factor
|
|
197
|
+
if pos != len(text):
|
|
198
|
+
raise ValueError("cannot read {!r}".format(text[pos:]))
|
|
199
|
+
if len(stack) != 1:
|
|
200
|
+
raise ValueError("a bracket is not closed")
|
|
201
|
+
if not stack[0]:
|
|
202
|
+
raise ValueError("no elements")
|
|
203
|
+
return stack[0]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _repeats_bare(text):
|
|
207
|
+
"""True when an element follows itself with no count between ("CC"):
|
|
208
|
+
nobody writes a sum formula like that, so such text is a SMILES."""
|
|
209
|
+
symbols = re.findall(r"[A-Z][a-z]?\d*", text)
|
|
210
|
+
return any(a == b and not re.search(r"\d", a)
|
|
211
|
+
for a, b in zip(symbols, symbols[1:]))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------- SMILES
|
|
215
|
+
def smiles_counts(text):
|
|
216
|
+
"""A SMILES's atoms, hydrogens included, as `{symbol: count}`."""
|
|
217
|
+
from rdkit import Chem, RDLogger
|
|
218
|
+
RDLogger.DisableLog("rdApp.*")
|
|
219
|
+
mol = Chem.MolFromSmiles(str(text or "").strip())
|
|
220
|
+
if mol is None:
|
|
221
|
+
raise ValueError("not a SMILES")
|
|
222
|
+
counts = {}
|
|
223
|
+
for atom in Chem.AddHs(mol).GetAtoms():
|
|
224
|
+
symbol = atom.GetSymbol()
|
|
225
|
+
counts[symbol] = counts.get(symbol, 0.0) + 1.0
|
|
226
|
+
return counts
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ----------------------------------------------------------- compositions
|
|
230
|
+
# His grammar, word for word (`_TOKEN_RE` in calculate_sum_formula.py).
|
|
231
|
+
_COEF = r"\d*\.\d+|\d+"
|
|
232
|
+
_COMPONENT = re.compile(
|
|
233
|
+
r"\((?P<pkey>[^()]+)\)\s*(?P<pcoef>%s)?"
|
|
234
|
+
r"|(?P<bkey>[A-Za-z]+)\s*(?P<bcoef>%s)?"
|
|
235
|
+
r"|(?P<sep>[+\u00b7*\s])" % (_COEF, _COEF))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_composition(text):
|
|
239
|
+
"""`[(key, coefficient), ...]`, or ValueError."""
|
|
240
|
+
text = str(text or "").strip()
|
|
241
|
+
found = []
|
|
242
|
+
pos = 0
|
|
243
|
+
for match in _COMPONENT.finditer(text):
|
|
244
|
+
if match.start() != pos:
|
|
245
|
+
raise ValueError("cannot read {!r}".format(text[pos:match.start()]))
|
|
246
|
+
pos = match.end()
|
|
247
|
+
if match.group("sep") is not None:
|
|
248
|
+
continue
|
|
249
|
+
if match.group("pkey") is not None:
|
|
250
|
+
key, coef = match.group("pkey").strip(), match.group("pcoef")
|
|
251
|
+
else:
|
|
252
|
+
key, coef = match.group("bkey"), match.group("bcoef")
|
|
253
|
+
found.append((key, float(coef) if coef is not None else 1.0))
|
|
254
|
+
if pos != len(text):
|
|
255
|
+
raise ValueError("cannot read {!r}".format(text[pos:]))
|
|
256
|
+
if not found:
|
|
257
|
+
raise ValueError("no components")
|
|
258
|
+
return found
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def component_counts(key, blocks=None):
|
|
262
|
+
"""One component's atoms: a building block, else a sum formula, else
|
|
263
|
+
a SMILES written in the brackets."""
|
|
264
|
+
blocks = BUILDING_BLOCKS if blocks is None else blocks
|
|
265
|
+
if key in blocks:
|
|
266
|
+
return smiles_counts(blocks[key])
|
|
267
|
+
try:
|
|
268
|
+
return parse_formula(key)
|
|
269
|
+
except ValueError:
|
|
270
|
+
pass
|
|
271
|
+
try:
|
|
272
|
+
return smiles_counts(key)
|
|
273
|
+
except ValueError:
|
|
274
|
+
raise ValueError("{} is no building block, formula or SMILES"
|
|
275
|
+
.format(key))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def composition_counts(text, blocks=None):
|
|
279
|
+
total = {}
|
|
280
|
+
for key, coef in parse_composition(text):
|
|
281
|
+
for symbol, count in component_counts(key, blocks).items():
|
|
282
|
+
total[symbol] = total.get(symbol, 0.0) + count * coef
|
|
283
|
+
return total
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _uses_blocks(text, blocks):
|
|
287
|
+
"""True when a composition names a building block that is not simply
|
|
288
|
+
an element too (Zn and Co are both: "ZnO" is a sum formula)."""
|
|
289
|
+
try:
|
|
290
|
+
return any(key in blocks and not _is_element(key)
|
|
291
|
+
for key, _c in parse_composition(text))
|
|
292
|
+
except ValueError:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------ calculating
|
|
297
|
+
def calculate(text, reading=AUTO, blocks=None):
|
|
298
|
+
"""`text` as a `Result`: read as asked, or - AUTO - as the first that
|
|
299
|
+
fits of a composition with a known building block, a sum formula (not
|
|
300
|
+
one with an element twice in a row, "CC", which is a SMILES), a SMILES,
|
|
301
|
+
and a composition of formulas and SMILES."""
|
|
302
|
+
text = str(text or "").strip()
|
|
303
|
+
blocks = BUILDING_BLOCKS if blocks is None else blocks
|
|
304
|
+
if not text:
|
|
305
|
+
return Result(error="Type a formula, a SMILES or a composition.")
|
|
306
|
+
if not available():
|
|
307
|
+
return Result(error="RDKit is not installed: pip install rdkit")
|
|
308
|
+
tries = {
|
|
309
|
+
FORMULA: lambda: parse_formula(text),
|
|
310
|
+
SMILES: lambda: smiles_counts(text),
|
|
311
|
+
COMPOSITION: lambda: composition_counts(text, blocks),
|
|
312
|
+
}
|
|
313
|
+
if reading != AUTO:
|
|
314
|
+
try:
|
|
315
|
+
return Result(reading, tries[reading]())
|
|
316
|
+
except ValueError as exc:
|
|
317
|
+
return Result(reading, error="Not {}: {}".format(
|
|
318
|
+
TITLES[reading], exc))
|
|
319
|
+
errors = []
|
|
320
|
+
if _uses_blocks(text, blocks):
|
|
321
|
+
try:
|
|
322
|
+
return Result(COMPOSITION, tries[COMPOSITION]())
|
|
323
|
+
except ValueError as exc:
|
|
324
|
+
errors.append(str(exc))
|
|
325
|
+
# A formula and a SMILES may both read ("CO", "CCO"): then an element
|
|
326
|
+
# twice in a row with no count ("CC") says SMILES, else the formula.
|
|
327
|
+
found = {}
|
|
328
|
+
for kind in (FORMULA, SMILES):
|
|
329
|
+
try:
|
|
330
|
+
found[kind] = tries[kind]()
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
errors.append(str(exc))
|
|
333
|
+
if FORMULA in found and SMILES in found:
|
|
334
|
+
kind = SMILES if _repeats_bare(text) else FORMULA
|
|
335
|
+
return Result(kind, found[kind])
|
|
336
|
+
for kind in (FORMULA, SMILES):
|
|
337
|
+
if kind in found:
|
|
338
|
+
return Result(kind, found[kind])
|
|
339
|
+
try:
|
|
340
|
+
return Result(COMPOSITION, tries[COMPOSITION]())
|
|
341
|
+
except ValueError as exc:
|
|
342
|
+
errors.append(str(exc))
|
|
343
|
+
return Result(error="Not a formula, a SMILES or a composition "
|
|
344
|
+
"({}).".format(errors[0] if errors else "?"))
|
dscpanel/core/numbers.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""How a number is WRITTEN: the format strings of the house style.
|
|
2
|
+
|
|
3
|
+
A format is Python's own percent format for ONE number - `%.2f`, `%+.1f`,
|
|
4
|
+
`%.0f` - because that is what a Python user already writes, and a second
|
|
5
|
+
mini-language would be one more thing to learn. `{:.2f}` and a bare `.2f`
|
|
6
|
+
are taken too and mean the same.
|
|
7
|
+
|
|
8
|
+
Two decisions, both on the side of the number being honest:
|
|
9
|
+
|
|
10
|
+
* **A format is the number, and at most a UNIT.** `%.0f degF` asks for
|
|
11
|
+
the value IN Fahrenheit: the unit is a conversion, done by
|
|
12
|
+
`core/labels.py`, never a relabelling. Any other text is refused - words
|
|
13
|
+
belong in the label - and so is a unit the program does not know.
|
|
14
|
+
* **`%.3g` means three SIGNIFICANT FIGURES, written out.** Python's `%g`
|
|
15
|
+
drops trailing zeros (1.50 becomes "1.5", which claims less than was
|
|
16
|
+
measured) and switches to exponent notation at 1000 ("1.23e+03" on a
|
|
17
|
+
figure). Here `%.Ng` rounds to N significant figures and writes them all,
|
|
18
|
+
in fixed notation: 13.2, 1.50, 0.0523, 141, 1230. That is what a default
|
|
19
|
+
of "3 significant figures" for enthalpies needs: a small enthalpy keeps
|
|
20
|
+
its digits instead of vanishing behind a fixed number of decimals.
|
|
21
|
+
|
|
22
|
+
UI-free: plain strings and floats.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
#: One conversion, and nothing else around it.
|
|
30
|
+
_SPEC = re.compile(r"^%([-+ 0#]*)(\d*)(?:\.(\d+))?([fFeEgGd])$")
|
|
31
|
+
#: The number part at the start of a format, in any of the three spellings,
|
|
32
|
+
#: and whatever follows it (a unit, or nothing).
|
|
33
|
+
_LEAD = re.compile(r"^(%[-+ 0#]*\d*(?:\.\d+)?[fFeEgGd]"
|
|
34
|
+
r"|\{:[-+ 0#]*\d*(?:\.\d+)?[fFeEgGd]\}"
|
|
35
|
+
r"|[-+ 0#]*\d*(?:\.\d+)?[fFeEgGd](?![A-Za-z]))(.*)$")
|
|
36
|
+
|
|
37
|
+
#: The built-in formats, by what the number is.
|
|
38
|
+
TEMPERATURE = "%.0f" # an onset, an endset, a Tg: whole degrees
|
|
39
|
+
VALUE = "%.3g" # an enthalpy, a step: three significant figures
|
|
40
|
+
OFFSET = "%+.1f" # the template's add_yoffset_markers
|
|
41
|
+
MASS = "%.0f" # a mass at a temperature: "99 %"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalise(spec, unit_of=None):
|
|
45
|
+
"""`spec` as a canonical format - `%.2f`, or `%.2f K` with a unit - or
|
|
46
|
+
None if it is not one.
|
|
47
|
+
|
|
48
|
+
Accepts `%.2f`, `{:.2f}` and `.2f`. A unit after the number is kept only
|
|
49
|
+
when `unit_of` (a function: text -> canonical unit, or None) knows it;
|
|
50
|
+
without `unit_of`, a format may not carry one (an axis's numbers sit on
|
|
51
|
+
its ticks, so their unit is the axis's). Anything else is refused.
|
|
52
|
+
"""
|
|
53
|
+
if spec is None:
|
|
54
|
+
return None
|
|
55
|
+
text = str(spec).strip()
|
|
56
|
+
match = _LEAD.match(text)
|
|
57
|
+
if not match:
|
|
58
|
+
return None
|
|
59
|
+
number, rest = match.group(1), match.group(2).strip()
|
|
60
|
+
if number.startswith("{:"):
|
|
61
|
+
number = "%" + number[2:-1]
|
|
62
|
+
elif not number.startswith("%"):
|
|
63
|
+
number = "%" + number
|
|
64
|
+
if not _SPEC.match(number):
|
|
65
|
+
return None
|
|
66
|
+
if not rest:
|
|
67
|
+
return number
|
|
68
|
+
unit = unit_of(rest) if unit_of is not None else None
|
|
69
|
+
return "{} {}".format(number, unit) if unit else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _any_unit(text):
|
|
73
|
+
return str(text).strip() or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def split(spec):
|
|
77
|
+
"""`(number format, unit or None)` of a canonical format."""
|
|
78
|
+
if not spec:
|
|
79
|
+
return None, None
|
|
80
|
+
parts = str(spec).split(" ", 1)
|
|
81
|
+
return parts[0], (parts[1] if len(parts) > 1 else None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_valid(spec, unit_of=None):
|
|
85
|
+
return normalise(spec, unit_of) is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write(value, spec, fallback=VALUE):
|
|
89
|
+
"""`value` written with `spec` (or `fallback` when `spec` is no format).
|
|
90
|
+
|
|
91
|
+
Never raises: a paint call is the last place for an exception, and a
|
|
92
|
+
format that does not parse falls back rather than blanking the figure.
|
|
93
|
+
"""
|
|
94
|
+
if value is None:
|
|
95
|
+
return "?"
|
|
96
|
+
try:
|
|
97
|
+
value = float(value)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return "?"
|
|
100
|
+
if value != value or abs(value) == float("inf"):
|
|
101
|
+
return "?"
|
|
102
|
+
# Only the NUMBER is written here; a unit in the format is the
|
|
103
|
+
# caller's to convert to (`core/labels.py`).
|
|
104
|
+
spec = (split(normalise(spec, _any_unit))[0]
|
|
105
|
+
or split(normalise(fallback, _any_unit))[0] or VALUE)
|
|
106
|
+
flags, width, precision, kind = _SPEC.match(spec).groups()
|
|
107
|
+
if kind in "gG":
|
|
108
|
+
digits = int(precision) if precision not in (None, "") else 6
|
|
109
|
+
text = significant(value, max(1, digits))
|
|
110
|
+
if "+" in flags and value >= 0:
|
|
111
|
+
text = "+" + text
|
|
112
|
+
elif " " in flags and value >= 0:
|
|
113
|
+
text = " " + text
|
|
114
|
+
elif kind == "d":
|
|
115
|
+
text = ("%" + flags + width + "d") % int(round(value))
|
|
116
|
+
else:
|
|
117
|
+
text = spec % value
|
|
118
|
+
return _no_negative_zero(text)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def significant(value, digits):
|
|
122
|
+
"""`value` rounded to `digits` significant figures, in fixed notation,
|
|
123
|
+
with every one of those figures written (trailing zeros kept)."""
|
|
124
|
+
if value == 0:
|
|
125
|
+
return "0" if digits <= 1 else "0." + "0" * (digits - 1)
|
|
126
|
+
exponent = int(math.floor(math.log10(abs(value))))
|
|
127
|
+
rounded = round(value, digits - 1 - exponent)
|
|
128
|
+
if rounded != 0:
|
|
129
|
+
# 9.996 to three figures is 10.0: the rounding moved the exponent.
|
|
130
|
+
exponent = int(math.floor(math.log10(abs(rounded))))
|
|
131
|
+
decimals = max(0, digits - 1 - exponent)
|
|
132
|
+
return "%.*f" % (decimals, rounded)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _no_negative_zero(text):
|
|
136
|
+
""""-0" and "-0.0" are zero: a sign on a rounded zero is noise."""
|
|
137
|
+
stripped = text.strip()
|
|
138
|
+
if stripped.startswith("-") and not any(c in "123456789"
|
|
139
|
+
for c in stripped):
|
|
140
|
+
return text.replace("-", "", 1)
|
|
141
|
+
return text
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------- typed sums
|
|
145
|
+
#: What a typed sum may hold: numbers, the four operations, brackets. A
|
|
146
|
+
#: comma is a decimal point, as everywhere a number is typed here.
|
|
147
|
+
_SUM_CHARACTERS = re.compile(r"^[0-9.,+\-*/() ]*$")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def evaluate(text):
|
|
151
|
+
"""A typed number or simple sum as a float: "255-20", "3*(1,5+2)",
|
|
152
|
+
"-4". None if it is not one (or divides by zero).
|
|
153
|
+
|
|
154
|
+
Only + - * / and brackets; walked from Python's parse tree, never `eval`.
|
|
155
|
+
"""
|
|
156
|
+
text = str(text or "").strip()
|
|
157
|
+
if not text or not _SUM_CHARACTERS.match(text):
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
tree = ast.parse(text.replace(",", "."), mode="eval")
|
|
161
|
+
value = _sum_of(tree.body)
|
|
162
|
+
except (SyntaxError, ValueError, ZeroDivisionError, OverflowError,
|
|
163
|
+
RecursionError):
|
|
164
|
+
return None
|
|
165
|
+
if value is None or not math.isfinite(value):
|
|
166
|
+
return None
|
|
167
|
+
return float(value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def values(text):
|
|
171
|
+
"""Several typed numbers, as floats - `[]` for none, None when any is
|
|
172
|
+
not a number. Separated by ";", ", " or a space before the next number:
|
|
173
|
+
a comma with no space is a decimal point ("261,5"). Each may be a sum
|
|
174
|
+
(`evaluate`)."""
|
|
175
|
+
parts = [part for part in re.split(r"\s*;\s*|,\s+|\s+(?=[-+]?\d)",
|
|
176
|
+
str(text or "").strip())
|
|
177
|
+
if part.strip()]
|
|
178
|
+
out = []
|
|
179
|
+
for part in parts:
|
|
180
|
+
value = evaluate(part)
|
|
181
|
+
if value is None:
|
|
182
|
+
return None
|
|
183
|
+
out.append(value)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_sum(text):
|
|
188
|
+
"""True if `text` holds an operation, not just a number: "255-20" does,
|
|
189
|
+
"-4" and "1,5" do not."""
|
|
190
|
+
body = str(text or "").strip().lstrip("+-")
|
|
191
|
+
return any(c in body for c in "+-*/()")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_OPERATIONS = {ast.Add: lambda a, b: a + b, ast.Sub: lambda a, b: a - b,
|
|
195
|
+
ast.Mult: lambda a, b: a * b, ast.Div: lambda a, b: a / b}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _sum_of(node):
|
|
199
|
+
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
|
|
200
|
+
return float(node.value)
|
|
201
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op,
|
|
202
|
+
(ast.USub, ast.UAdd)):
|
|
203
|
+
value = _sum_of(node.operand)
|
|
204
|
+
return -value if isinstance(node.op, ast.USub) else value
|
|
205
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS:
|
|
206
|
+
return _OPERATIONS[type(node.op)](_sum_of(node.left),
|
|
207
|
+
_sum_of(node.right))
|
|
208
|
+
raise ValueError("not a sum")
|