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/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 "?"))
@@ -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")