rosetta-eclab 0.1.1__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,708 @@
1
+ """Formula library: what code knows about each EC-Lab quantity.
2
+
3
+ Each quantity has relation checks (its defining relation to other columns,
4
+ with the expected scale factors for the units the vendor uses) and/or a shape
5
+ check. Relations are written from the definitions of the quantities (see
6
+ docs/definitions.md for sources); scale factors cover the unit variants seen in
7
+ parsers and vendor exports (mA·h vs C, W·h, µF, degrees).
8
+
9
+ Conventions (EC-Lab): time in s, current in mA, potential in V, charge in mA·h
10
+ (or C), energy in W·h, power in W, impedance in Ω, frequency in Hz, phase in
11
+ degrees, capacitance in µF, impedance amplitudes |I| in A.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Callable
18
+
19
+ import numpy as np
20
+
21
+ from .engine import Context, Evidence, fit, scale_match
22
+
23
+ MAH_PER_MA_S = 1 / 3600.0 # mA * s -> mA·h
24
+ C_PER_MA_S = 1 / 1000.0 # mA * s -> C
25
+ WH_PER_V_MA_S = 1 / 3.6e6 # V * mA * s -> W·h
26
+ W_PER_V_MA = 1 / 1000.0 # V * mA -> W
27
+
28
+
29
+ def d0(x):
30
+ return np.concatenate([[0.0], np.diff(x)])
31
+
32
+
33
+ def cumsum_by(x, key):
34
+ x = np.nan_to_num(np.asarray(x, dtype="f8"))
35
+ key = np.asarray(key)
36
+ out = np.empty_like(x)
37
+ acc = 0.0
38
+ for i in range(len(x)):
39
+ if i and key[i] != key[i - 1]:
40
+ acc = 0.0
41
+ acc += x[i]
42
+ out[i] = acc
43
+ return out
44
+
45
+
46
+ def since_change(t, key):
47
+ t = np.asarray(t, dtype="f8")
48
+ key = np.asarray(key)
49
+ out = np.empty_like(t)
50
+ start = t[0] if len(t) else 0.0
51
+ for i in range(len(t)):
52
+ if i and key[i] != key[i - 1]:
53
+ start = t[i]
54
+ out[i] = t[i] - start
55
+ return out
56
+
57
+
58
+ def sign_changes_count(i):
59
+ """Cumulative count of sign changes of a current (zeros keep the previous sign)."""
60
+ s = np.sign(np.asarray(i, dtype="f8"))
61
+ for k in range(1, len(s)):
62
+ if s[k] == 0:
63
+ s[k] = s[k - 1]
64
+ return np.concatenate([[0], np.cumsum(s[1:] * s[:-1] < 0)])
65
+
66
+
67
+ @dataclass
68
+ class Relation:
69
+ detail: str
70
+ needs: tuple # required known columns; an inner tuple means "any of these"
71
+ pred: Callable[[Context], np.ndarray | None]
72
+ scales: list[float] = field(default_factory=list) # expected |a| values (empty: any scale)
73
+ signed: bool = False # True: sign of a matters (scales given with sign)
74
+ gate: Callable[[Context], float] | None = None # multiplies this relation's score
75
+ rows: str = "auto" # "dc": rows without an impedance sweep, "eis": sweep rows, "all"; auto = by quantity family
76
+ abs_target: bool = False # fit |target| (e.g. when the only current available is |P/E|)
77
+ reliable: bool = True # an exact definition: if testable and it fails (with the right units), the name is ruled out
78
+
79
+
80
+ @dataclass
81
+ class Shape:
82
+ detail: str
83
+ test: Callable[[Context], float] # 0..1
84
+ intrinsic: bool = True # a property of the column alone (False: depends on other columns)
85
+
86
+
87
+ @dataclass
88
+ class Quantity:
89
+ name: str
90
+ relations: list[Relation] = field(default_factory=list)
91
+ shapes: list[Shape] = field(default_factory=list)
92
+ prior: float = 0.0 # weak score when nothing else applies
93
+ gate: Callable[[Context], float] | None = None # multiplies the final score (a property the quantity must have)
94
+
95
+ def best_reliable_relation(self, ctx: Context) -> Evidence | None:
96
+ """Best evidence from exact (reliable) relations only; None if none is testable here."""
97
+ saved = (self.shapes, self.relations)
98
+ try:
99
+ self.shapes, self.relations = [], [r for r in self.relations if r.reliable]
100
+ ev = self.evaluate(ctx)
101
+ finally:
102
+ self.shapes, self.relations = saved
103
+ return ev if ev is not None and ev.kind == "relation" else None
104
+
105
+ def evaluate(self, ctx: Context) -> Evidence | None:
106
+ best: Evidence | None = None
107
+ for rel in self.relations:
108
+ if not all(any(n in ctx.known for n in (need if isinstance(need, tuple) else (need,))) for need in rel.needs):
109
+ continue
110
+ with np.errstate(all="ignore"):
111
+ try:
112
+ p = rel.pred(ctx)
113
+ except Exception:
114
+ p = None
115
+ if p is None:
116
+ continue
117
+ mask = row_mask(ctx, rel.rows if rel.rows != "auto" else ("eis" if self.name in EIS_FAMILY else "dc"))
118
+ tgt = np.asarray(np.abs(np.asarray(ctx.target, dtype="f8")) if rel.abs_target else ctx.target)
119
+ fits = [fit(tgt, p, rel.reliable)] + ([fit(tgt[mask], np.asarray(p)[mask], rel.reliable)] if mask is not None else [])
120
+ fits = [f for f in fits if f is not None]
121
+ if not fits:
122
+ continue
123
+ f = max(fits, key=lambda f: f[0])
124
+ r2, a, _ = f
125
+ sm = scale_match(a if rel.signed else abs(a), rel.scales)
126
+ score = max(0.0, r2) ** 4 * (1.0 if sm is None else 0.25 + 0.75 * sm)
127
+ if sm is None:
128
+ score *= 0.9 # an unscaled relation is weaker evidence
129
+ if rel.gate is not None:
130
+ score *= float(rel.gate(ctx))
131
+ if not rel.reliable:
132
+ score = min(score, 0.7) # an approximate definition is never decisive on its own
133
+ if self.name in CONTINUOUS and _integer_valued(ctx):
134
+ score *= 0.3 # charges, energies and potentials are not exact integers
135
+ ev = Evidence(self.name, score, "relation", f"{rel.detail}: r2 {r2:.4f}, scale {a:.4g}",
136
+ {"r2": r2, "reliable": rel.reliable, "scale_match": 1.0 if sm is None else sm})
137
+ if best is None or ev.score > best.score:
138
+ best = ev
139
+ for sh in self.shapes:
140
+ s = float(sh.test(ctx))
141
+ ev = Evidence(self.name, s, "shape", sh.detail)
142
+ if best is None or ev.score > best.score:
143
+ best = ev
144
+ if best is None and self.prior > 0:
145
+ best = Evidence(self.name, self.prior, "prior", "no relation testable; prior only")
146
+ if best is not None and self.gate is not None:
147
+ best.score *= float(self.gate(ctx))
148
+ if best is not None and self.name in COUNTERS and not _all_integers(ctx):
149
+ best.score *= 0.05 # counters and codes are whole numbers by definition
150
+ if best is not None and self.name in EIS_FAMILY and ctx.col("freq") is None and self.name != "freq":
151
+ best.score *= 0.2 # impedance quantities need a frequency sweep in the file
152
+ exp = DTYPES.get(self.name)
153
+ if best is not None and exp and ctx.target_dtype not in exp:
154
+ best.score *= 0.3
155
+ best.detail += f" (stored as {ctx.target_dtype}, expected {'/'.join(sorted(exp))})"
156
+ return best
157
+
158
+
159
+ EIS_FAMILY = {"freq", "|Z|", "Re(Z)", "-Im(Z)", "Phase(Z)", "Cs", "Cp", "Cs⁻²", "Cp⁻²", "|Ewe|", "|I|", "|Ece|",
160
+ "|Zce|", "Re(Zce)", "-Im(Zce)", "Phase(Zce)", "|Zwe-ce|", "Re(Zwe-ce)", "-Im(Zwe-ce)", "Phase(Zwe-ce)",
161
+ "THD Ewe", "THD I", "THD Ece", "NSD Ewe", "NSD I", "NSD Ece", "NSR Ewe", "NSR I", "NSR Ece", "z cycle",
162
+ "Re(Y)", "Im(Y)", "|Y|", "Phase(Y)", "|Z1|", "|Z2|", "Phase(Z1)", "-Im(Z1)", "-Im(Z2)", "|Estack|",
163
+ "|Zstack|", "Phase(Zstack)"}
164
+
165
+
166
+ CONTINUOUS = {"dq", "dQ", "(Q-Qo)", "Q charge or discharge", "Energy we charge", "Energy we discharge", "|Energy|",
167
+ "Energy we", "Pwe", "Ewe", "I", "Capacitance charge", "Capacitance discharge", "x"}
168
+
169
+
170
+ COUNTERS = {"half cycle", "cycle number", "z cycle", "Ns", "I Range", "counter inc."}
171
+
172
+
173
+ def _all_integers(ctx) -> bool:
174
+ y = np.asarray(ctx.target, dtype="f8")
175
+ y = y[np.isfinite(y)]
176
+ return y.size > 0 and bool(np.allclose(y, np.round(y)))
177
+
178
+
179
+ def _integer_valued(ctx) -> bool:
180
+ y = np.asarray(ctx.target, dtype="f8")
181
+ y = y[np.isfinite(y)]
182
+ return y.size > 3 and np.ptp(y) > 0 and bool(np.allclose(y, np.round(y)))
183
+
184
+
185
+ def row_mask(ctx, which: str):
186
+ """Boolean rows to fit on, or None for all rows."""
187
+ f = ctx.col("freq")
188
+ if f is None or which == "all":
189
+ return None
190
+ m = (f > 0) if which == "eis" else ~(f > 0)
191
+ return m if m.sum() >= 5 else None
192
+
193
+
194
+ # --- shape helpers ----------------------------------------------------------------
195
+
196
+ def _fin(ctx):
197
+ y = np.asarray(ctx.target, dtype="f8")
198
+ return y[np.isfinite(y)]
199
+
200
+
201
+ def is_counter(ctx, step=1.0, monotonic=True) -> float:
202
+ y = _fin(ctx)
203
+ if y.size < 3 or not np.allclose(y, np.round(y)):
204
+ return 0.0
205
+ d = np.diff(y)
206
+ nz = d[d != 0]
207
+ if nz.size == 0:
208
+ return 0.0
209
+ ok_steps = np.mean(np.isclose(nz, step) | np.isclose(nz, 2 * step)) # a skipped value (rest step) is allowed
210
+ mono = np.mean(d >= 0)
211
+ return float(ok_steps * (mono if monotonic else 1.0))
212
+
213
+
214
+ def is_clock(ctx) -> float:
215
+ y = _fin(ctx)
216
+ if y.size < 3 or ctx.target_dtype != "<f8":
217
+ return 0.0
218
+ d = np.diff(y)
219
+ if np.mean(d >= 0) < 0.999 or np.mean(d > 0) < 0.9:
220
+ return 0.0
221
+ return 1.0
222
+
223
+
224
+ def is_log_spaced(ctx) -> float:
225
+ y = _fin(ctx)
226
+ if (y < 0).any():
227
+ return 0.0
228
+ y = y[y > 0] # sweeps in modulo-bat (MB) files are separated by zero rows
229
+ if y.size < 4 or y.max() / y.min() < 10:
230
+ return 0.0
231
+ r = y[1:] / y[:-1]
232
+ r = r[r != 1.0]
233
+ if r.size < 3:
234
+ return 0.0
235
+ lr = np.log(r)
236
+ return float(np.mean(np.abs(lr - np.median(lr)) < 0.15 * abs(np.median(lr)) + 1e-9))
237
+
238
+
239
+ def held_at(a, b, tol=0.005) -> float:
240
+ """Share of rows where two potentials agree within tol (V): measured potential held at its set point."""
241
+ if a is None or b is None:
242
+ return 0.0
243
+ a, b = np.asarray(a, dtype="f8"), np.asarray(b, dtype="f8")
244
+ m = np.isfinite(a) & np.isfinite(b)
245
+ return float(np.mean(np.abs(a[m] - b[m]) <= tol)) if m.any() else 0.0
246
+
247
+
248
+ def setpoint_shape(ctx) -> float:
249
+ """A float column that holds a few values exactly between changes."""
250
+ y = _fin(ctx)
251
+ if ctx.target_dtype != "<f4" or y.size < 3 or np.allclose(y, np.round(y)):
252
+ return 0.5 * piecewise_constant(ctx)
253
+ few = np.unique(y).size <= 20
254
+ return (0.68 if few else 0.5) * piecewise_constant(ctx)
255
+
256
+
257
+ def sweep_shape(ctx) -> float:
258
+ """Programmed ramp: steps of (nearly) one constant size, changing sign at the vertices."""
259
+ y = _fin(ctx)
260
+ if ctx.target_dtype != "<f4" or y.size < 10:
261
+ return 0.0
262
+ d = np.diff(y)
263
+ d = d[d != 0]
264
+ if d.size < 8:
265
+ return 0.0
266
+ mag = np.median(np.abs(d))
267
+ if mag == 0:
268
+ return 0.0
269
+ same = np.mean(np.abs(np.abs(d) - mag) <= 0.05 * mag)
270
+ return 0.8 if same > 0.9 else 0.0
271
+
272
+
273
+ def current_held(ctx) -> float:
274
+ """Share of rows where the column sits within 0.5% (or 1 µA) of a current set point."""
275
+ u = ctx.unit("control") or ""
276
+ ctl = ctx.col("control")
277
+ if ctl is None or "mA" not in u or "V" in u.replace("V/mA", ""):
278
+ return 0.0
279
+ if "V" in u and not unit_explained(ctx):
280
+ return 0.0 # control/V/mA also holds potentials (CC-CV steps): a potential on its set point is no current
281
+ a = np.asarray(ctx.target, dtype="f8")
282
+ m = np.isfinite(a) & np.isfinite(ctl) & (ctl != 0)
283
+ if m.sum() < 3:
284
+ return 0.0
285
+ return float(np.mean(np.abs(a[m] - ctl[m]) <= np.maximum(1e-3, 0.02 * np.abs(ctl[m]))))
286
+
287
+
288
+ def ece_explains(c) -> float:
289
+ """Ewe - Ece is evidence only where subtracting Ece leaves at most a tenth of what Ewe alone leaves unexplained.
290
+ Ece is often nearly flat, and then any column that follows Ewe (a potential set point) also fits Ewe - Ece."""
291
+ e, ce = _e(c), c.col("Ece")
292
+ a, b = fit(c.target, e - ce), fit(c.target, e)
293
+ if not a or not b or 1.0 - b[0] < 1e-4:
294
+ return 0.0
295
+ return 1.0 if 1.0 - a[0] <= 0.1 * (1.0 - b[0]) else 0.0
296
+
297
+
298
+ def potential_control(c):
299
+ u = (c.unit("control") or "")
300
+ return c.col("control") if "V" in u else None
301
+
302
+
303
+ def _near(a: float, expected: list[float], tol: float = 0.15) -> bool:
304
+ return any(abs(abs(a) / e - 1) <= tol for e in expected if e)
305
+
306
+
307
+ def unit_explained(ctx) -> bool:
308
+ """True if the column IS a current, charge or power in the file's own units: it fits the derived current
309
+ with scale 1, the accumulated charge with a mA·h or C scale, or E·I with a W scale (r2 >= 0.8).
310
+ Measured on public files: no potential (Ewe: 0/120, Ece: 2/31) passes this, most currents and charges do."""
311
+ i, t = _i(ctx), ctx.col("time")
312
+ y = ctx.target
313
+ with np.errstate(all="ignore"):
314
+ if i is not None:
315
+ f = fit(y, i)
316
+ if f and f[0] >= 0.8 and _near(f[1], [1.0]):
317
+ return True
318
+ if t is not None:
319
+ f = fit(y, np.cumsum(np.nan_to_num(i * d0(t))))
320
+ if f and f[0] >= 0.8 and _near(f[1], [MAH_PER_MA_S, C_PER_MA_S]):
321
+ return True
322
+ e = ctx.col("Ewe")
323
+ if e is not None:
324
+ f = fit(y, e * i)
325
+ if f and f[0] >= 0.8 and _near(f[1], [W_PER_V_MA]):
326
+ return True
327
+ return False
328
+
329
+
330
+ def potential_like(ctx) -> float:
331
+ """f4 values in the volts range that are either smooth or held nearly flat (not integer codes, not noise)."""
332
+ if ctx.target_dtype != "<f4" or follows_sweep(ctx) or current_held(ctx) > 0.5 or unit_explained(ctx):
333
+ return 0.0
334
+ y = _fin(ctx)
335
+ if y.size < 4 or np.abs(y).max() > 10 or np.ptp(y) == 0 or np.allclose(y, np.round(y)):
336
+ return 0.0
337
+ a, b = y[:-1] - y[:-1].mean(), y[1:] - y[1:].mean()
338
+ den = np.sqrt((a * a).sum() * (b * b).sum())
339
+ ac = float((a * b).sum() / den) if den else 0.0
340
+ flat = np.std(y) <= 0.01 * max(abs(np.mean(y)), 1e-3) and abs(np.mean(y)) >= 0.01
341
+ d = np.diff(y)
342
+ accumulator = y[0] == 0 and (np.mean(d >= 0) > 0.98 or np.mean(d <= 0) > 0.98)
343
+ if accumulator:
344
+ return 0.0 # starts at zero and only grows (or only falls): a running total, not a potential
345
+ return 1.0 if (ac > 0.5 or flat) else 0.0
346
+
347
+
348
+ def follows_sweep(ctx) -> bool:
349
+ """True if the column varies systematically with the frequency sweep (rank correlation with log f)."""
350
+ f = ctx.col("freq")
351
+ if f is None:
352
+ return False
353
+ y = np.asarray(ctx.target, dtype="f8")
354
+ m = np.isfinite(y) & (f > 0)
355
+ if m.sum() < 5 or np.ptp(y[m]) == 0:
356
+ return False
357
+ ry = np.argsort(np.argsort(y[m]))
358
+ rf = np.argsort(np.argsort(np.log(f[m])))
359
+ r = np.corrcoef(ry, rf)[0, 1]
360
+ return bool(np.isfinite(r) and abs(r) > 0.6)
361
+
362
+
363
+ def second_potential(ctx) -> float:
364
+ """A potential-like column in a file whose working-electrode potential is already known."""
365
+ if ctx.col("Ewe") is None:
366
+ return 0.0
367
+ return 0.75 * potential_like(ctx) * (1.0 if piecewise_constant(ctx) < 0.9 else 0.2)
368
+
369
+
370
+ def _charge_like(ctx) -> bool:
371
+ """True if the column follows a charge (cumulative current) closely enough to be one."""
372
+ i, t = _i(ctx), ctx.col("time")
373
+ if i is None or t is None:
374
+ return False
375
+ with np.errstate(all="ignore"):
376
+ f = fit(ctx.target, np.cumsum(np.nan_to_num(i * d0(t))))
377
+ return bool(f and f[0] > 0.95)
378
+
379
+
380
+ def piecewise_constant(ctx) -> float:
381
+ """Share of steps where the value does not change at all (set points, counters)."""
382
+ y = _fin(ctx)
383
+ if y.size < 3:
384
+ return 0.0
385
+ return float(np.mean(np.diff(y) == 0))
386
+
387
+
388
+ def phase_shape(ctx) -> float:
389
+ if ctx.target_dtype != "<f4" or ctx.col("|Z|") is None or ctx.col("freq") is None:
390
+ return 0.0
391
+ y = np.asarray(ctx.target, dtype="f8")
392
+ f = ctx.col("freq")
393
+ sweep = np.isfinite(f) & (f > 0) & np.isfinite(y)
394
+ if sweep.sum() < 5 or np.nanmax(np.abs(y)) > 90:
395
+ return 0.0
396
+ ys = y[sweep]
397
+ if np.ptp(ys) < 1.0:
398
+ return 0.0 # a phase angle swings by degrees over a sweep; a held potential does not
399
+ off = ~sweep & np.isfinite(y)
400
+ if off.any() and np.mean(y[off] == 0) < 0.9:
401
+ return 0.0 # outside the sweep rows a phase is not recorded (zero)
402
+ return 0.7
403
+
404
+
405
+ def rcmp_shape(ctx) -> float:
406
+ y = _fin(ctx)
407
+ if ctx.target_dtype != "<f4" or y.size < 3 or (y <= 0).any():
408
+ return 0.0
409
+ rel = np.ptp(y) / np.mean(y)
410
+ return 0.75 if 0 < rel < 1e-5 or (np.ptp(y) == 0 and np.mean(y) > 0.5) else 0.0
411
+
412
+
413
+ def ns_shape(ctx) -> float:
414
+ y = _fin(ctx)
415
+ if y.size < 3 or not np.allclose(y, np.round(y)) or y.min() not in (0, 1) or y.max() > 200 or (y < 0).any():
416
+ return 0.0
417
+ d = np.diff(y)
418
+ moves = d[d != 0]
419
+ if moves.size and (np.mean(d == 0) < 0.5 or np.mean((moves == 1) | (moves < 0)) < 0.8):
420
+ return 0.0 # a sequence index holds for runs of rows and steps forward by one (or loops back)
421
+ s = 0.6
422
+ ctl = ctx.col("control")
423
+ if ctl is not None:
424
+ ch_y = np.diff(y) != 0
425
+ ch_c = np.diff(ctl) != 0
426
+ if ch_y.any():
427
+ s += 0.2 * float(np.mean(ch_c[ch_y])) # Ns changes where the control changes
428
+ return s
429
+
430
+
431
+ def irange_shape(ctx) -> float:
432
+ y = _fin(ctx)
433
+ if y.size < 3 or not np.allclose(y, np.round(y)) or y.min() < 1 or y.max() > 200:
434
+ return 0.0
435
+ u = np.unique(y)
436
+ if u.size > 12:
437
+ return 0.0
438
+ if ctx.col("Ns") is not None:
439
+ return 0.75 # the file's sequence index is known, and this is the other small code column
440
+ return 0.7 if (u.size <= 6 and np.mean(np.diff(y) == 0) > 0.9) else 0.5
441
+
442
+
443
+ def is_code(ctx, lo=0, hi=200, max_distinct=8) -> float:
444
+ y = _fin(ctx)
445
+ if y.size == 0 or not np.allclose(y, np.round(y)):
446
+ return 0.0
447
+ u = np.unique(y)
448
+ return 1.0 if (u.size <= max_distinct and u.min() >= lo and u.max() <= hi) else 0.0
449
+
450
+
451
+ # --- expected storage types (public parser maps: yadg 7.0.1 and galvani 0.5.0) ---------
452
+
453
+ def expected_dtypes() -> dict[str, set[str]]:
454
+ from galvani import BioLogic
455
+ from yadg.extractors.eclab.mpr_columns import data_columns
456
+
457
+ from spike2 import vocab
458
+
459
+ out: dict[str, set[str]] = {}
460
+ for dt, name, _unit in data_columns.values():
461
+ out.setdefault(vocab.canonical(name), set()).add(dt)
462
+ for full, dt in BioLogic.VMPdata_colID_dtype_map.values():
463
+ name = full.split("/")[0] if not full.startswith("Q charge/discharge") else "Q charge/discharge"
464
+ out.setdefault(vocab.canonical(name), set()).add(dt)
465
+ return out
466
+
467
+
468
+ DTYPES = expected_dtypes()
469
+
470
+
471
+ # --- the library ------------------------------------------------------------------
472
+
473
+ def _t(c):
474
+ return c.col("time")
475
+
476
+
477
+ def _i(c):
478
+ """Current in mA: measured if present, otherwise derived from charge or power columns."""
479
+ i = c.col("I")
480
+ if i is not None:
481
+ return i
482
+ t = c.col("time")
483
+ if t is not None:
484
+ dt = d0(t)
485
+ dt[dt == 0] = np.nan
486
+ dq = c.col("dq")
487
+ if dq is not None:
488
+ return dq / dt * (1000.0 if (c.unit("dq") or "").strip() == "C" else 3600.0)
489
+ qq = c.col("(Q-Qo)")
490
+ if qq is not None:
491
+ return d0(qq) / dt * (1000.0 if (c.unit("(Q-Qo)") or "").strip() == "C" else 3600.0)
492
+ p, e = c.col("Pwe"), c.col("Ewe")
493
+ if p is not None and e is not None:
494
+ return 1000.0 * p / e
495
+ return None
496
+
497
+
498
+ CURRENT = ("I", "dq", "(Q-Qo)", "Pwe") # any of these lets _i() produce a current
499
+
500
+
501
+ def _e(c):
502
+ return c.col("Ewe")
503
+
504
+
505
+ def build() -> dict[str, Quantity]:
506
+ L: dict[str, Quantity] = {}
507
+
508
+ def q(name, **kw):
509
+ L[name] = Quantity(name, **kw)
510
+
511
+ def i_dt(c): # mA*s per point; rows of an impedance sweep carry no DC charge
512
+ x = _i(c) * d0(_t(c))
513
+ return x
514
+ q("time", shapes=[Shape("monotonic f8 clock", is_clock)])
515
+ q("step time", relations=[
516
+ Relation("time since Ns changed", ("time", "Ns"), lambda c: since_change(_t(c), c.col("Ns")), [1.0]),
517
+ Relation("time since half cycle changed", ("time", "half cycle"), lambda c: since_change(_t(c), c.col("half cycle")), [1.0]),
518
+ Relation("time since control changed", ("time", "control"), lambda c: since_change(_t(c), c.col("control")), [1.0]),
519
+ Relation("time since cycle number changed", ("time", "cycle number"), lambda c: since_change(_t(c), c.col("cycle number")), [1.0]),
520
+ Relation("time since the record started (one step)", ("time",), lambda c: _t(c) - _t(c)[0], [1.0]),
521
+ Relation("a clock that restarts: time since its own last reset", ("time",),
522
+ lambda c: since_change(_t(c), np.cumsum(np.r_[0, np.diff(np.asarray(c.target, dtype="f8")) < 0])), [1.0]),
523
+ Relation("time since the sweep direction changed", ("time", "control"),
524
+ lambda c: since_change(_t(c), np.sign(d0(c.col("control")))), [1.0]),
525
+ ])
526
+ def i_dt_prev(c): # some sequences (modulo bat) integrate the previous row's current over the interval
527
+ i = _i(c)
528
+ return np.r_[0.0, i[:-1]] * d0(_t(c))
529
+
530
+ q("dq", relations=[Relation("I*dt per point", (CURRENT, "time"), i_dt, [MAH_PER_MA_S, C_PER_MA_S]),
531
+ Relation("previous-row I*dt per point", (CURRENT, "time"), i_dt_prev, [MAH_PER_MA_S, C_PER_MA_S]),
532
+ Relation("|I|*dt per point (current known only as |P/E|)", ("Pwe", "Ewe", "time"),
533
+ lambda c: np.abs(1000.0 * c.col("Pwe") / _e(c)) * d0(_t(c)), [MAH_PER_MA_S, C_PER_MA_S],
534
+ abs_target=True)])
535
+ q("(Q-Qo)", relations=[
536
+ Relation("cumulative I*dt", (CURRENT, "time"), lambda c: np.cumsum(np.nan_to_num(i_dt(c))), [MAH_PER_MA_S, C_PER_MA_S]),
537
+ Relation("cumulative previous-row I*dt", (CURRENT, "time"), lambda c: np.cumsum(np.nan_to_num(i_dt_prev(c))), [MAH_PER_MA_S, C_PER_MA_S]),
538
+ Relation("cumulative dq", ("dq",), lambda c: np.cumsum(np.nan_to_num(c.col("dq"))), [1.0, 3.6]),
539
+ Relation("I*dt summed since Ns changed (Qo reset by a linked technique)", (CURRENT, "time", "Ns"),
540
+ lambda c: cumsum_by(i_dt(c), c.col("Ns")), [MAH_PER_MA_S, C_PER_MA_S]),
541
+ ])
542
+ q("dQ", relations=[
543
+ Relation("I*dt summed within half cycle", (CURRENT, "time", "half cycle"), lambda c: cumsum_by(i_dt(c), c.col("half cycle")), [MAH_PER_MA_S, C_PER_MA_S]),
544
+ Relation("I*dt summed since Ns changed", (CURRENT, "time", "Ns"), lambda c: cumsum_by(i_dt(c), c.col("Ns")), [MAH_PER_MA_S, C_PER_MA_S]),
545
+ Relation("I*dt summed since control changed", (CURRENT, "time", "control"), lambda c: cumsum_by(i_dt(c), c.col("control")), [MAH_PER_MA_S, C_PER_MA_S]),
546
+ ])
547
+ q("Q charge or discharge", relations=[
548
+ Relation("I*dt summed within cycle", (CURRENT, "time", "half cycle"), lambda c: cumsum_by(i_dt(c), np.floor(c.col("half cycle") / 2)), [MAH_PER_MA_S, C_PER_MA_S]),
549
+ Relation("I*dt summed within half cycle", (CURRENT, "time", "half cycle"), lambda c: cumsum_by(i_dt(c), c.col("half cycle")), [MAH_PER_MA_S, C_PER_MA_S]),
550
+ Relation("dq summed within half cycle", ("dq", "half cycle"), lambda c: cumsum_by(c.col("dq"), c.col("half cycle")), [1.0, 3.6]),
551
+ Relation("I*dt summed within current-sign segment", (CURRENT, "time"), lambda c: cumsum_by(i_dt(c), sign_changes_count(_i(c))), [MAH_PER_MA_S, C_PER_MA_S]),
552
+ Relation("I*dt summed within sequence", (CURRENT, "time", "Ns"), lambda c: cumsum_by(i_dt(c), c.col("Ns")), [MAH_PER_MA_S, C_PER_MA_S]),
553
+ Relation("I*dt summed since control changed", (CURRENT, "time", "control"), lambda c: cumsum_by(i_dt(c), c.col("control")), [MAH_PER_MA_S, C_PER_MA_S]),
554
+ ])
555
+ q("half cycle", relations=[
556
+ Relation("count of current sign changes", (CURRENT,), lambda c: sign_changes_count(_i(c)), [1.0, 2.0]),
557
+ ], shapes=[Shape("integer counter stepping by 1 (no impedance sweep)", lambda c: 0.75 * is_counter(c) if c.col("freq") is None else 0.3 * is_counter(c))])
558
+ q("cycle number", relations=[
559
+ Relation("half cycle / 2", ("half cycle",), lambda c: np.floor(c.col("half cycle") / 2), [1.0]),
560
+ ], shapes=[Shape("integer-valued counter stored as float", lambda c: 0.75 * is_counter(c))])
561
+ q("z cycle", shapes=[Shape("integer counter in a sequence with impedance", lambda c: 0.75 * is_counter(c) if c.col("freq") is not None else 0.0)])
562
+ q("Ns", shapes=[Shape("sequence index from 0 that changes with the control", lambda c: ns_shape(c))])
563
+ q("I Range", shapes=[Shape("instrument range code (few values, not starting at 0)", lambda c: irange_shape(c))])
564
+ q("Pwe", relations=[Relation("Ewe*I", ("Ewe", CURRENT), lambda c: _e(c) * _i(c), [W_PER_V_MA])])
565
+ q("Ewe", relations=[
566
+ Relation("Pwe/I", ("Pwe", ("I", "dq", "(Q-Qo)")), lambda c: c.col("Pwe") / _i(c), [1000.0]),
567
+ Relation("follows potential control", ("control",), lambda c: c.col("control"), [1.0]),
568
+ ], shapes=[Shape("held at the potential set point", lambda c: 0.9 * held_at(c.target, potential_control(c)), intrinsic=False)])
569
+ q("I", relations=[
570
+ Relation("Pwe/Ewe", ("Pwe", "Ewe"), lambda c: c.col("Pwe") / _e(c), [1000.0]),
571
+ Relation("d(dq)/dt", ("dq", "time"), lambda c: c.col("dq") / d0(_t(c)), [3600.0, 1000.0]),
572
+ Relation("d(Q-Qo)/dt", ("(Q-Qo)", "time"), lambda c: d0(c.col("(Q-Qo)")) / d0(_t(c)), [3600.0, 1000.0]),
573
+ Relation("follows current control", ("control",), lambda c: c.col("control"), [1.0]),
574
+ ])
575
+ L["I"].shapes.append(Shape("measured current held at the current set point",
576
+ lambda c: (0.95 if current_held(c) > 0.5 else 0.85 * current_held(c))
577
+ * (1.0 if piecewise_constant(c) < 0.9 else 0.2), intrinsic=False))
578
+ for rel in L["I"].relations: # a measured current moves; a value held exactly between changes is a set point
579
+ rel.gate = lambda c: 1.0 - 0.8 * min(1.0, max(0.0, piecewise_constant(c) - 0.8) / 0.18)
580
+ q("control", relations=[
581
+ Relation("set point followed by I", (CURRENT,), lambda c: _i(c), [1.0],
582
+ gate=lambda c: 0.4 + 0.6 * piecewise_constant(c)), # a current set point holds still between changes
583
+ Relation("set point followed by Ewe", ("Ewe",), lambda c: _e(c), [1.0]),
584
+ ], shapes=[Shape("piecewise-constant set point", lambda c: setpoint_shape(c), intrinsic=False),
585
+ Shape("programmed potential sweep (piecewise linear at a constant scan rate)", lambda c: sweep_shape(c)),
586
+ Shape("potential set point that Ewe sits on", lambda c: 0.85 * held_at(c.target, _e(c)) * (0.5 + 0.5 * piecewise_constant(c)), intrinsic=False)])
587
+ e_i_dt = lambda c: _e(c) * i_dt(c) # noqa: E731
588
+ cycle_key = lambda c: np.floor(c.col("half cycle") / 2) # noqa: E731 EC-Lab resets per-cycle totals every 2 half cycles
589
+ q("Energy we charge", relations=[
590
+ Relation("E*I*dt summed while charging, per cycle", ("Ewe", CURRENT, "time", "half cycle"),
591
+ lambda c: cumsum_by(np.where(_i(c) > 0, e_i_dt(c), 0.0), cycle_key(c)), [WH_PER_V_MA_S]),
592
+ Relation("E*I*dt summed while charging, per half cycle", ("Ewe", CURRENT, "time", "half cycle"),
593
+ lambda c: cumsum_by(np.where(_i(c) > 0, e_i_dt(c), 0.0), c.col("half cycle")), [WH_PER_V_MA_S]),
594
+ Relation("E*I*dt summed while charging", ("Ewe", CURRENT, "time"),
595
+ lambda c: np.cumsum(np.nan_to_num(np.where(_i(c) > 0, e_i_dt(c), 0.0))), [WH_PER_V_MA_S])])
596
+ q("Energy we discharge", relations=[
597
+ Relation("E*I*dt summed while discharging, per cycle", ("Ewe", CURRENT, "time", "half cycle"),
598
+ lambda c: cumsum_by(np.where(_i(c) < 0, -e_i_dt(c), 0.0), cycle_key(c)), [WH_PER_V_MA_S]),
599
+ Relation("E*I*dt summed while discharging, per half cycle", ("Ewe", CURRENT, "time", "half cycle"),
600
+ lambda c: cumsum_by(np.where(_i(c) < 0, -e_i_dt(c), 0.0), c.col("half cycle")), [WH_PER_V_MA_S]),
601
+ Relation("E*I*dt summed while discharging", ("Ewe", CURRENT, "time"),
602
+ lambda c: np.cumsum(np.nan_to_num(np.where(_i(c) < 0, -e_i_dt(c), 0.0))), [WH_PER_V_MA_S])])
603
+ q("|Energy|", relations=[
604
+ Relation("|E*I|*dt summed", ("Ewe", CURRENT, "time"), lambda c: np.cumsum(np.nan_to_num(np.abs(e_i_dt(c)))), [WH_PER_V_MA_S]),
605
+ Relation("|E*I|*dt summed per half cycle", ("Ewe", CURRENT, "time", "half cycle"),
606
+ lambda c: cumsum_by(np.abs(e_i_dt(c)), c.col("half cycle")), [WH_PER_V_MA_S])])
607
+ q("Energy we", relations=[
608
+ Relation("E*I*dt summed", ("Ewe", CURRENT, "time"), lambda c: np.cumsum(np.nan_to_num(e_i_dt(c))), [WH_PER_V_MA_S]),
609
+ Relation("E*I*dt summed per half cycle", ("Ewe", CURRENT, "time", "half cycle"),
610
+ lambda c: cumsum_by(e_i_dt(c), c.col("half cycle")), [WH_PER_V_MA_S])])
611
+
612
+ # --- impedance (PEIS/GEIS) ---
613
+ w = lambda c: 2 * np.pi * c.col("freq") # noqa: E731
614
+ re, mim, mod = (lambda c: c.col("Re(Z)")), (lambda c: c.col("-Im(Z)")), (lambda c: c.col("|Z|"))
615
+ q("freq", shapes=[Shape("log-spaced positive frequencies", is_log_spaced)])
616
+ q("|Z|", relations=[Relation("sqrt(Re^2+Im^2)", ("Re(Z)", "-Im(Z)"), lambda c: np.hypot(re(c), mim(c)), [1.0]),
617
+ Relation("|Ewe|/|I|", ("|Ewe|", "|I|"), lambda c: c.col("|Ewe|") / c.col("|I|"), [1.0])])
618
+ q("Re(Z)", relations=[Relation("|Z|*cos(phase)", ("|Z|", "Phase(Z)"), lambda c: mod(c) * np.cos(np.radians(c.col("Phase(Z)"))), [1.0]),
619
+ Relation("sqrt(|Z|^2-Im^2)", ("|Z|", "-Im(Z)"), lambda c: np.sqrt(np.maximum(mod(c) ** 2 - mim(c) ** 2, 0)), [1.0])])
620
+ q("-Im(Z)", relations=[Relation("-|Z|*sin(phase)", ("|Z|", "Phase(Z)"), lambda c: -mod(c) * np.sin(np.radians(c.col("Phase(Z)"))), [1.0]),
621
+ Relation("sqrt(|Z|^2-Re^2)", ("|Z|", "Re(Z)"), lambda c: np.sqrt(np.maximum(mod(c) ** 2 - re(c) ** 2, 0)), [1.0])])
622
+ q("Phase(Z)", relations=[Relation("atan2(Im, Re) in degrees", ("Re(Z)", "-Im(Z)"), lambda c: np.degrees(np.arctan2(-mim(c), re(c))), [1.0])])
623
+ L["Phase(Z)"].shapes.append(Shape("phase angle in degrees (within ±90) that follows the frequency sweep",
624
+ lambda c: phase_shape(c), intrinsic=False))
625
+ q("Cs", relations=[Relation("1/(w*(-Im)) in µF", ("freq", "-Im(Z)"), lambda c: 1.0 / (w(c) * mim(c)), [1e6])])
626
+ q("Cp", relations=[Relation("(-Im)/(w*|Z|^2) in µF", ("freq", "-Im(Z)", "Re(Z)"),
627
+ lambda c: mim(c) / (w(c) * (re(c) ** 2 + mim(c) ** 2)), [1e6])])
628
+ q("|Ewe|", relations=[Relation("|Z|*|I|", ("|Z|", "|I|"), lambda c: mod(c) * c.col("|I|"), [1.0])])
629
+ q("|I|", relations=[Relation("|Ewe|/|Z|", ("|Ewe|", "|Z|"), lambda c: c.col("|Ewe|") / mod(c), [1.0])])
630
+ for el, amp in (("Zce", "|Ece|"), ):
631
+ q(f"|{el}|", relations=[Relation(f"{amp}/|I|", (amp, "|I|"), lambda c, amp=amp: c.col(amp) / c.col("|I|"), [1.0]),
632
+ Relation(f"sqrt(Re^2+Im^2) of {el}", (f"Re({el})", f"-Im({el})"),
633
+ lambda c, el=el: np.hypot(c.col(f"Re({el})"), c.col(f"-Im({el})")), [1.0])])
634
+ q(amp, relations=[Relation(f"|{el}|*|I|", (f"|{el}|", "|I|"), lambda c, el=el: c.col(f"|{el}|") * c.col("|I|"), [1.0])])
635
+ for el in ("Zce", "Zwe-ce"):
636
+ q(f"Re({el})", relations=[Relation(f"|{el}|*cos(phase)", (f"|{el}|", f"Phase({el})"),
637
+ lambda c, el=el: c.col(f"|{el}|") * np.cos(np.radians(c.col(f"Phase({el})"))), [1.0])])
638
+ q(f"-Im({el})", relations=[Relation(f"-|{el}|*sin(phase)", (f"|{el}|", f"Phase({el})"),
639
+ lambda c, el=el: -c.col(f"|{el}|") * np.sin(np.radians(c.col(f"Phase({el})"))), [1.0])])
640
+ q(f"Phase({el})", relations=[Relation(f"atan2 of {el}", (f"Re({el})", f"-Im({el})"),
641
+ lambda c, el=el: np.degrees(np.arctan2(-c.col(f"-Im({el})"), c.col(f"Re({el})"))), [1.0])])
642
+ q("|Zwe-ce|", relations=[Relation("sqrt(Re^2+Im^2) of Zwe-ce", ("Re(Zwe-ce)", "-Im(Zwe-ce)"),
643
+ lambda c: np.hypot(c.col("Re(Zwe-ce)"), c.col("-Im(Zwe-ce)")), [1.0]),
644
+ Relation("|Z(we) - Z(ce)|", ("Re(Z)", "-Im(Z)", "Re(Zce)", "-Im(Zce)"),
645
+ lambda c: np.hypot(re(c) - c.col("Re(Zce)"), mim(c) - c.col("-Im(Zce)")), [1.0])])
646
+ for name, amp in (("THD Ewe", "|Ewe|"), ("THD I", "|I|")):
647
+ base = "Ewe" if "Ewe" in name else "I"
648
+ q(name, relations=[Relation(f"sqrt(sum of {base} harmonics^2)/{amp} in %",
649
+ (amp, f"|{base} h2|"),
650
+ lambda c, base=base, amp=amp: np.sqrt(sum(np.nan_to_num(c.col(f"|{base} h{k}|")) ** 2
651
+ for k in range(2, 8) if c.col(f"|{base} h{k}|") is not None)) / c.col(amp),
652
+ [100.0])])
653
+ # Capacitance charge/discharge (EC-Lab manual 2014, §5.1.5): Q / ΔE within the sequence, ΔE from its first point.
654
+ def start_value(x, key):
655
+ x, key = np.asarray(x, dtype="f8"), np.asarray(key)
656
+ out = np.empty_like(x)
657
+ cur = x[0] if len(x) else 0.0
658
+ for i in range(len(x)):
659
+ if i and key[i] != key[i - 1]:
660
+ cur = x[i]
661
+ out[i] = cur
662
+ return out
663
+
664
+ def _cap(c, key, sign):
665
+ i = _i(c)
666
+ q_seg = np.abs(cumsum_by(np.where(sign * i > 0, i * d0(_t(c)), 0.0), key))
667
+ de = np.abs(_e(c) - start_value(_e(c), key))
668
+ with np.errstate(all="ignore"):
669
+ out = q_seg / de
670
+ y = np.asarray(c.target, dtype="f8")
671
+ bad = ~np.isfinite(out) | (de < 0.05 * np.nanmax(de)) | (y == 0) # ΔE near zero is unstable; 0 = not reported
672
+ out[bad] = np.nan
673
+ return out
674
+
675
+ def capacitance(c, key_name, sign):
676
+ return _cap(c, c.col(key_name), sign)
677
+
678
+ def capacitance_cycle(c, sign):
679
+ return _cap(c, np.floor(c.col("half cycle") / 2), sign)
680
+
681
+ capacitance_relations = []
682
+ for cname, sign in (("Capacitance charge", 1.0), ("Capacitance discharge", -1.0)):
683
+ q(cname, relations=[
684
+ Relation("Q/ΔE within half cycle (either current sign)", ("Ewe", CURRENT, "time", "half cycle"),
685
+ lambda c, sign=sign: capacitance(c, "half cycle", -sign)),
686
+ Relation(f"Q/ΔE within half cycle ({'charging' if sign > 0 else 'discharging'})", ("Ewe", CURRENT, "time", "half cycle"),
687
+ lambda c, sign=sign: capacitance(c, "half cycle", sign)),
688
+ Relation(f"Q/ΔE within cycle ({'charging' if sign > 0 else 'discharging'})", ("Ewe", CURRENT, "time", "half cycle"),
689
+ lambda c, sign=sign: capacitance_cycle(c, sign)),
690
+ Relation(f"Q/ΔE within sequence ({'charging' if sign > 0 else 'discharging'})", ("Ewe", CURRENT, "time", "Ns"),
691
+ lambda c, sign=sign: capacitance(c, "Ns", sign)),
692
+ ])
693
+ for cname in ("Capacitance charge", "Capacitance discharge"):
694
+ for rel in L[cname].relations:
695
+ rel.reliable = False # ΔE near zero makes Q/ΔE unstable; never use it to rule the name out
696
+ L[cname].gate = lambda c: 1.0 if np.nanmax(np.abs(np.asarray(c.target, dtype="f8"))) >= 1.0 else 0.1 # µF of a cell
697
+ q("Ece", shapes=[Shape("a second smooth potential next to a known Ewe", second_potential, intrinsic=False)])
698
+ q("Ewe-Ece", relations=[Relation("Ewe - Ece", ("Ewe", "Ece"), lambda c: _e(c) - c.col("Ece"), [1.0],
699
+ gate=ece_explains)])
700
+ L["Ewe"].shapes.append(Shape("the file's working-electrode potential is missing and this column is one",
701
+ lambda c: 0.7 * potential_like(c) * (1.0 if piecewise_constant(c) < 0.9 else 0.2), intrinsic=False))
702
+ q("Rcmp", shapes=[Shape("compensated resistance: positive, constant up to float rounding", rcmp_shape)])
703
+ for name in ("Analog IN 1", "Analog IN 2", "NSD Ewe", "NSR Ewe", "NSD I", "NSR I"):
704
+ q(name, prior=0.0)
705
+ return L
706
+
707
+
708
+ LIBRARY = build()