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.
- rosetta_eclab/__init__.py +0 -0
- rosetta_eclab/cli.py +113 -0
- rosetta_eclab/data/stats.json +1 -0
- rosetta_eclab/data/stats_features.npy +0 -0
- rosetta_eclab/debug.py +85 -0
- rosetta_eclab/engine.py +203 -0
- rosetta_eclab/evaluate.py +196 -0
- rosetta_eclab/fallback.py +130 -0
- rosetta_eclab/golden.py +74 -0
- rosetta_eclab/library.py +708 -0
- rosetta_eclab/misses.py +39 -0
- rosetta_eclab/mprfile.py +178 -0
- rosetta_eclab-0.1.1.dist-info/METADATA +136 -0
- rosetta_eclab-0.1.1.dist-info/RECORD +31 -0
- rosetta_eclab-0.1.1.dist-info/WHEEL +5 -0
- rosetta_eclab-0.1.1.dist-info/entry_points.txt +2 -0
- rosetta_eclab-0.1.1.dist-info/licenses/LICENSE +674 -0
- rosetta_eclab-0.1.1.dist-info/top_level.txt +2 -0
- spike2/__init__.py +0 -0
- spike2/agent.py +372 -0
- spike2/api_check.py +117 -0
- spike2/baselines.py +153 -0
- spike2/corpus.py +162 -0
- spike2/dsl.py +167 -0
- spike2/elim.py +52 -0
- spike2/features.py +137 -0
- spike2/lock.py +59 -0
- spike2/run.py +218 -0
- spike2/score.py +223 -0
- spike2/tasks.py +355 -0
- spike2/vocab.py +126 -0
rosetta_eclab/library.py
ADDED
|
@@ -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()
|