Modular toolkit for electrochemical impedance spectroscopy (EIS) analysis with Distribution of Relaxation Times (DRT) support.
Key features:
- Reproducibility - Objective, data-driven parameter selection
- Modularity - Usable as CLI and Python library
- Advanced optimization - Multi-start, Differential Evolution
Example output:
Supported data formats:
- Gamry DTA - native format (automatic metadata parsing, ZCURVE block)
- CSV - three columns with a header row, e.g.
frequency,Z_real,Z_imag- Column names are split into words (case-insensitive), so units and
labels do not matter:
Frequency (Hz),Z' (Ohms),Z Real,Re(Z)/Ohm,Z'(a)(ZView). Frequency - a wordfreq,frequency,hzor a leadingf; real part -re,real,zreal,zr,Z', ...; imaginary part -im,imag,imaginary,zimag,zi,Z'', ... A word for another quantity or a derived column (Re(Y),Z' err,Zreal fit) rules a column out (full rules in theload_csv_datadocstring) - Unit prefixes are converted:
Freq (kHz),Z' (kOhm),Re(Z)/mOhm,|Z| (MΩ)are read as Hz and Ohm (prefixes G, M, k, m, µ or u; case-sensitive as in SI, soMis mega andmmilli;Kcounts as kilo), and a frequency inrad/sis divided by 2 pi. The conversion is reported as a warning. A header with two different prefixes, or with m/M on a unit written in one case (mhz,MOHM: milli or mega?), is rejected - A column named with a leading minus (
-Im(Z),-Z'', EC-Lab; also-Z') holds the negative and is negated - Polar form:
|Z|(orZmod,mod,magnitude,abs) and a phase (phase,Zphz,theta,phi,arg, ...; degrees unless the header saysrad,-phasenegated). Used only when Re(Z) and Im(Z) are not both named, so exports carrying both forms read the Re/Im columns - If no name matches, the first three columns are used positionally (with a warning); a header that names only some of the three, or one of them twice, is rejected. A file without a header row (the first line is numbers) is read the same way, from its first line
- Delimiter: comma, semicolon, or tab (auto-detection); whitespace when the header has none of them (aligned columns, names without spaces)
- Several sweeps in one file (the frequency steps back to the start) are rejected with the line range of each: read as one spectrum they would corrupt every analysis. Split the file and load one sweep at a time
- Decimal format: US (dot) and European (comma for semicolon-delimited)
- Comments: lines starting with
#are ignored - Preamble: lines an instrument writes ahead of the header (
Sample: X,Date: ...) are skipped; the header is the first line with as many fields as the first row of numbers - Examples: example/example_eis_data.csv
- Column names are split into words (case-insensitive), so units and
labels do not matter:
Changelog: CHANGELOG.md
Requires Python 3.11 or newer.
git clone https://github.com/chmelat/eis_analysis
cd eis_analysis
pip install -e .After installation, the eis command is available system-wide.
Windows users: Python and Git are not preinstalled on Windows - see the step-by-step guide in Installation on Windows.
For alternative installation methods, see Installation options.
# Basic analysis (KK + Z-HIT validation + DRT)
eis data.DTA
# With circuit fitting
eis data.DTA --circuit "R(100) - (R(5000) | C(1e-6))"
# Save plots to files
eis data.DTA --save results --format pdfRun eis --help for all options.
# KK + Z-HIT validation + DRT analysis (default)
eis data.DTAOutput: Kramers-Kronig residuals, mu metric, Z-HIT reconstruction, DRT spectrum with detected peaks.
# Fit equivalent circuit
eis data.DTA --circuit "R(100) - (R(5000) | C(1e-6))"
# Use Differential Evolution for global optimization (default)
eis data.DTA --circuit "R(100) - (R(5000) | Q(1e-6, 0.9))"
# Use multi-start for local optimization
eis data.DTA --circuit "..." --optimizer multistart --multistart 20Repeat --circuit to fit several candidates on the same data and rank them
by information criteria:
eis data.DTA \
--circuit "R(100) - (R(5000) | C(1e-6))" \
--circuit "R(100) - (R(5000) | Q(1e-6, 0.9))" \
--circuit "R(100) - (R(5000) | Q(1e-6, 0.9)) - (R(100) | C(1e-5))"Circuit comparison (n = 160 residuals, weighting = modulus)
rank -c Circuit k err% fit/KK dAIC dBIC cond
1 1 R(10)-(R(200)|C(2e-5)) 3 2.38 0.8 2.4 0.0 1.2e+00
2 3 R(10)-(R(150)|C(2e-5))-(R(50)|C(1e-6)) 5 2.33 0.8 0.0 3.7 1.3e+08
3 2 R(10)-(R(200)|Q(2e-5,0.9)) 4 2.38 0.8 4.4 5.1 6.0e+01
dAIC/dBIC < 2: indistinguishable, 4-7: noticeable, > 10: decisive
fit/KK: fit residual / Lin-KK noise; * > 2, the model misses structure the data resolve
Selected by BIC: R(10)-(R(200)|C(2e-5)) (candidate 1)
Adding an element almost always lowers the residual, so the fit error alone
cannot tell a real improvement from fitting the noise. AIC and BIC charge for
each free parameter k and answer the question the error cannot: does the
data support the extra element?
Only differences are meaningful - below 2 the models are indistinguishable,
4-7 is a noticeable difference, above 10 is decisive. BIC charges ln(n) per
parameter against AIC's 2, so it penalizes complexity roughly 2.5x harder and
is what selects the reported winner. The example above is the case they
disagree on: the two-branch circuit has the lowest residual and wins on AIC,
but BIC rejects the extra branch - and its condition number, eight orders
higher, shows why. The ! flag marks cond > 1e10, where the covariance is
numerically unusable; below it, read the column anyway.
fit/KK asks the question before the ranking: does the candidate fit to the
noise at all? It is the same ratio as the Fit vs. noise line of each fit
(see below). Here all three are at the noise, so the ranking is meaningful.
When every candidate is above 2, the table says so - the ranking then only
orders wrong models, and the circuit needs another element first. The column
is - with --no-kk.
rank orders by BIC; -c is the position on the command line, which is also
the number the figures are saved under (<prefix>_fit_1, _fit_2, ...).
Values are comparable only within one run: change the weighting or the frequency range between candidates and the comparison is meaningless.
The BIC winner is what --analyze-oxide receives, and which circuit that was
is written to the log. A candidate that fails to fit is reported in the table
and skipped rather than ending the run.
Detailed documentation: doc/MODEL_SELECTION_AIC_BIC.md
- what the criteria measure, a worked example of the arithmetic, and what they cannot tell you.
# Automatic Voigt chain fitting
eis data.DTA --voigt-chain
# With automatic element count optimization
eis data.DTA --voigt-chain --voigt-auto-MA smooth description of the spectrum, not a circuit of its processes (see
Voigt chain below). The suggested circuit is in the default run, under the
DRT (Suggested circuit).
# Thickness calculation from capacitance
eis data.DTA --circuit "R(100) - (R(5000) | C(1e-6))" --analyze-oxide
# With custom parameters
eis data.DTA --circuit "..." --analyze-oxide --epsilon-r 22 --area 0.5
# Inverse: permittivity from a known thickness (SEM/TEM)
eis data.DTA --circuit "..." --analyze-oxide --thickness 25# Process multiple files without interactive display
for f in *.DTA; do
eis "$f" --save "${f%.DTA}" --no-show
doneWith --circuit or --voigt-chain, each run also writes <name>_fit.json: the fitted
parameters with their uncertainties, so a batch over a series gives the
parameter trend without reading the console output. Several --circuit
candidates write <name>_fit_1.json, <name>_fit_2.json, ... instead, one per
candidate in command-line order.
Data quality verification using Lin-KK test. Validates causality, linearity, and stability of measured data.
# Default (included in standard analysis)
eis data.DTA
# Skip KK validation
eis data.DTA --no-kk
# Custom mu threshold
eis data.DTA --mu-threshold 0.80New to KK validation? Start with the intuitive introduction: doc/KK_INTUITION.md.
Detailed documentation: doc/LinKK_analysis.md
Non-parametric K-K validation using Hilbert transform. Runs by default alongside Lin-KK. Faster and provides complementary assessment.
# Both Lin-KK and Z-HIT run by default
eis data.DTA
# Disable Z-HIT (use only Lin-KK)
eis data.DTA --no-zhit
# Disable Lin-KK (use only Z-HIT)
eis data.DTA --no-kkZ-HIT is not only a validator ("was the system stationary?") but also a repair.
Where the low-frequency modulus drifts during the measurement while the phase
stays sound - a coating taking up water, whose impedance falls while the lowest
decades are being recorded - |Z| can be reconstructed from the phase and the
circuit fitted against the reconstruction instead of the measurement.
# Fit the circuit against the Z-HIT reconstruction, everything else unchanged
eis data.DTA --circuit 'R()-(R()|Q())-(R()|Q())' --fit-on zhit
# Propagate the reconstruction to R_inf, DRT and oxide analysis as well
eis data.DTA --fit-on allThe run reports how far the data moved, so a spectrum the correction does nothing for is visible as such, and the saved fit plot notes that its "Data" curve is the reconstructed one.
It corrects drift, not noise, and the distinction is not cosmetic: the second-order term differentiates the phase, so phase noise is amplified rather than smoothed. On a stationary spectrum with 1% noise the reconstruction never makes the fitted resistances clearly better, and sometimes up to three times worse, than the measurement does. Use it when the per-point residual check reports a systematic deviation at the lowest frequencies - that is drift - and not to clean up a scattered one.
Even on noise-free, exactly K-K compliant data the reconstruction is not
exact: Z-HIT truncates an asymptotic series after the phase-derivative term,
and the error peaks where the phase bends most - around a relaxation, not at
the edges of the frequency range. It is up to ~3% in |Z| at an ideal RC and
0.3-0.5% on the mean for CPE-like data. That matters mainly for
--fit-on all, where R_inf and the DRT read the reconstruction wherever a
relaxation sits, including the high-frequency end far from the drift being
corrected, so the run warns about it. Details in
doc/ZHIT_REVIEW.md, 1.3.
Detailed documentation: doc/ZHIT_IMPLEMENTATION_SPEC.md
When a Gamry file was recorded with the THD option on, its ZCURVE carries
the total harmonic distortion of current and voltage per point, and the run
prints a "THD" section right after Z-HIT and draws THD [%] against frequency
for both channels with the threshold line (saved as <prefix>_thd under
--save). Nothing to switch on; files without the columns print and draw
nothing.
THD (Gamry harmonic analysis)
Current: median 0.16 %, max 2.17 % at 7.94e-03 Hz
Voltage: median 0.04 %, max 0.11 % at 1.99e+01 Hz
! Current THD above 1 % at 3/72 points (3.16e-03 - 7.94e-03 Hz): ...
THD is the only direct instrument diagnostic of linearity; KK and Z-HIT see a nonlinear response only indirectly. In potentiostatic EIS the current THD is the sample's response, the voltage THD the purity of the excitation. The 1 % threshold bounds the |Z| error from nonlinearity: for a cubic nonlinearity the fundamental is off by 3x the 3rd-harmonic ratio, so ~3 % at most, below the 5 % residual threshold of the validations. A point above it in the response channel is a nonlinear response (excitation amplitude too large) or noise where the signal is small - typically the lowest frequencies at nA currents; in the excitation channel it is distorted excitation. The report does not try to tell which, nor which channel is the response (that depends on the control mode). Points without a THD value are left out of the counts and reported as missing. Gamry stores THD as a fraction although its header gives no unit; the report shows percent.
Leaving the THD option on permanently costs nothing measurable (same time per point with it on and off on a Reference 620).
Distribution of Relaxation Times - model-free method for impedance data analysis. The regularization parameter is selected automatically by a hybrid search: GCV (Generalized Cross-Validation) gives one estimate, the corner of an L-curve searched over +-1.5 decades around it gives another, and the larger of the two is used - under the non-negativity constraint of the DRT both tend to pick too small a lambda. A corner more than a decade below GCV is reported as a warning.
New to DRT? Start with the intuitive introduction: doc/DRT_INTUITION.md.
# Default DRT with auto-lambda
eis data.DTA
# Manual lambda selection
eis data.DTA --lambda 1e-6
# GMM peak detection (more robust)
eis data.DTA --peak-method gmm
# R_inf from an R-L-(R|Q) fit of the top two decades instead of the HF median
eis data.DTA --ri-fit
# Series inductance in the DRT model (default: auto)
eis data.DTA --drt-inductance on
# Weighting of the DRT data term (default: sqrt)
eis data.DTA --drt-weighting modulus
# Extend the tau grid past the slow end of the window (off by default)
eis data.DTA --tau-extend autoThe least-squares term is weighted per frequency by 1/sqrt|Z| by default.
Unweighted, the low-frequency points with the largest |Z| dominate the residual
and a small arc next to a large one is smoothed away; sqrt recovers such arcs
while staying robust when the measurement noise does not scale with |Z|.
modulus (1/|Z|) is the better choice when the noise is proportional to |Z|,
but under constant noise it amplifies the high-frequency points and drives
auto-lambda to the edge of its range. uniform gives the unweighted DRT of
versions before 0.38. The weights are rescaled so that the weighted data keep
the norm of the unweighted data, so a given --lambda means about the same
regularization under every weighting.
The tau grid spans the measured window, 1/(2 pi f_max) to 1/(2 pi f_min).
A process slower than the lowest frequency then has nowhere to go and NNLS
heaps it into the last bin (reported as pile-up). --tau-extend DECADES
continues the grid past the slow end at the same spacing; --tau-extend auto
does so only when it helps: when the unextended DRT piles up at the slow end,
it tries 0.5, 1.0 and 1.5 decades and keeps the smallest that clears the
pile-up, else none. It does not extend a capacitive low-frequency end (a
blocking electrode, a CPE with n close to 1), where the extension would turn
the capacitance into a huge fictitious peak. Anything placed past the window
is an extrapolation from the high-frequency flank of its response: such peaks
are marked past window, and the share of R_pol there is reported. The
extension is off by default because on spectra whose low-frequency arc stays
open over more than a decade it only moves the heap further out and inflates
R_pol.
The DRT kernel alone yields only Im(Z) < 0, so an inductive high-frequency end
(cabling, typically above ~100 kHz) has nowhere to go and deforms gamma: with a
10 uH series inductance the reconstruction error was 17 % even with the exact
R_inf. The model therefore carries a series j*omega*L term, unregularized and
solved together with gamma (lambda is selected with it in the system).
--drt-inductance auto (default) adds it only when the top decade has a point
with Im(Z) > 0; on forces it, with a warning if it then finds an L on a
non-inductive end, and off gives the DRT of versions before 0.41. The fitted L
is printed in the DRT section. An inductance hidden under an arc still open at
f_max - Im(Z) < 0 at the top despite the cable - is not detected by auto;
a circuit fit with an L element shows it (cold-agent task T10: 1 uH, dBIC 459).
By default R_inf is the median of Re(Z) over the (up to 5) highest-frequency
points, which assumes the spectrum has already flattened onto the real axis at
f_max. A median more than 5 % above the two highest points (with Im(Z) <= 0)
reaches into an arc still open there; the first of them, an upper bound of
R_inf, is used instead, with a warning. Use --ri-fit when the spectrum has not flattened.
Flat means -Z'' at f_max small against Re(Z) and Re(Z) no longer falling. A
closing arc (a CPE tail, -Z''/Z' of a few percent at f_max) leaves the median
a few percent high without a warning - the warning needs 5 %. When R_inf
matters, run --ri-fit: one small fit, printed next to the HF bound, and the
DRT section states its difference from the HF median. With a circuit fit,
R_s is a fit parameter and depends on neither; R_inf feeds the DRT, R_pol and
the suggested circuit.
R_pol (from data) is the median Re(Z) of the (up to 5) lowest-frequency
points minus R_inf: a lower bound when an arc is still open at f_min
(cold-agent task T12: 180 Ohm, true 950), as is R_pol (from DRT integral);
only a fit can extrapolate past the window.
A local maximum of gamma counts as a peak only if the data need it. The DRT is
refitted with gamma forced to fall monotonically from the nearest taller peak
through the candidate, i.e. with the candidate as a mere shoulder of that peak,
and the loss of fit (pseudo chi^2, in units of the noise variance) must reach
16 (DRT_PEAK_DCHI2_MIN, about chi^2 with 3 degrees of freedom at
p = 0.001). The noise is the DRT's own residual (not the Lin-KK noise
estimate, which absorbs genuine misfit such as drift and is computed before
--f-min/--f-max); the "Peak detection" section prints it, Delta chi^2
for every peak and the rejected maxima. Unlike the former 3 % height
threshold, this keeps a resolved process three orders smaller in R next to a
large one and drops the lobes regularization splits one broad process into:
on 248 synthetic spectra (0-3 % noise) no spurious peak, against 114 with the
threshold. What it cannot do is separate what the DRT does not: two ZARCs
up to about a decade apart can give one maximum (cold-agent task T9: two
processes 0.8 decade apart, one peak, while BIC preferred two by 474), so the
peak count is a lower bound - fit one element more to check. A broad process
on the flank of a
response heaped against the slow end of the window is reported as part of
it. The test is calibrated for --drt-weighting sqrt (default) and
modulus; under uniform and proportional the solved problem departs from
the chi^2 it measures and it is less reliable. The suggested Voigt elements
are built from exactly these peaks. Their Suggestion quality checks the
bookkeeping, not the fit: POOR when sum(R_i) and R_pol differ by more than
2x, otherwise GOOD, ACCEPTABLE or UNCERTAIN by the number of warnings
(none, 1-2, more). A GOOD suggestion can still fail as a circuit - one
element standing in for two close processes adds up to R_pol just as well -
so fit it and read Fit vs. noise. The CLI says so under the suggested
circuit, and names the candidates worth comparing: one element more, and Q
in place of the ideal C.
Detailed documentation: doc/GCV_IMPLEMENTATION.md, doc/GMM_PEAK_DETECTION.md, doc/RINF_ESTIMATION.md
Elegant operator overloading syntax for circuit definition.
Supported elements:
| Element | Description | Example |
|---|---|---|
R(value) |
Resistor | R(100) |
G(value) |
Conductance, Y = G [S] | G(1e-9) |
C(value) |
Capacitor | C(1e-6) |
L(value) |
Inductor | L(1e-6) |
Q(Q, n) |
Constant Phase Element (CPE) | Q(1e-4, 0.8) |
W(sigma) |
Warburg (semi-infinite) | W(50) |
Ws(R_W, tau) |
Warburg finite-length, transmissive boundary (constant concentration, tanh; Z -> R_W at DC) | Ws(100, 1.0) |
Wo(R_W, tau) |
Warburg finite-space, reflective boundary (zero flux, coth; capacitive at DC) | Wo(100, 1.0) |
Wa(R_W, tau, gamma) |
Anomalous finite-space Warburg (Wo with free exponent; CPE with n = gamma at DC, gamma = 1 is Wo) | Wa(1e7, 30, 0.7) |
Wat(R_W, tau, gamma) |
Anomalous finite-length Warburg (Ws with free exponent; Z -> R_W at DC, gamma = 1 is Ws) | Wat(1e8, 300, 0.7) |
K(R, tau) |
Voigt with tau parametrization | K(1000, 1e-4) |
GE(sigma, tau) |
Gerischer (reaction-diffusion) | GE(100, 1e-3) |
CC(C_inf, dC, tau, alpha) |
Cole-Cole dielectric relaxation | CC(1e-8, 1e-7, 1e-3, 0.2) |
DQ(A, n, tau_min, U) |
Truncated CPE (bounded power-law DRT) | DQ(1.2e6, 0.57, 5e-2, 8) |
YG(C, p, tau) |
Young-Göhr passive layer (exponential conductivity profile) | YG(1e-5, 0.05, 0.1) |
The Gerischer element was G until v0.29.0 and is now GE; G is the
conductance. An old two-argument G(sigma, tau) in --circuit is rejected
with a message saying so.
Values in parentheses serve as initial guesses for the nonlinear fitting algorithm.
Values in quotes (e.g., R("100")) are treated as fixed constants and will not be fitted.
When to use G instead of R. The two are the same resistor - G(1e-9)
is R(1e9) - but they fit differently. If a parallel resistance is too large
for the measured window to determine, R runs into its upper bound of 10 GOhm.
That bound is an edge of the parameter box, not part of the model: the fit is
constrained rather than converged, the covariance collapses, and the reported
error comes back as spurious precision instead of a large uncertainty. Written
as G, the same limit is G = 0, which is inside the allowed range, so the
fit returns an ordinary symmetric interval:
G0 = 2.07e-10 +- 1.63e-10 S -> R > 2.4 GOhm
An interval that includes zero is the answer here, not a failure - it says the
data place a lower bound on the resistance and nothing more. Reach for G for
blocking coatings and intact oxide layers, where the barrier resistance may be
beyond what the frequency window can resolve; keep R when the resistance is
well determined, since it reads more directly.
Operators:
| Operator | Meaning | Example |
|---|---|---|
- |
Series connection | R(100) - C(1e-6) |
| |
Parallel connection | R(1000) | C(1e-6) |
Operator precedence: - has HIGHER precedence than | (Python rules).
Always use parentheses around parallel combinations: (R|C).
# Voigt element
eis data.DTA --circuit "R(100) - (R(5000) | C(1e-6))"
# Randles circuit with CPE
eis data.DTA --circuit "R(10) - (R(100) | Q(1e-4, 0.8))"
# With fixed parameter
eis data.DTA --circuit 'R("0.86") - (R(2.4e9) | Q(1e-10, 0.823))'Does every element earn its place? With --circuit (any --optimizer), each
parameter line carries an S, its significance - the largest relative change
it causes in |Z| anywhere in the measured window, S = max |dln|Z|/dlnP|:
Parameters:
R0 = 5.22e+02 +/- 8.29e+01 [95% CI: 3.81e+02, 7.15e+02] S=1.00
R1 = 7.48e+04 +/- 5.65e+03 [95% CI: 6.44e+04, 8.68e+04] S=0.99
C0 = 6.42e-08 +/- 6.34e-09 [95% CI: 5.28e-08, 7.80e-08] S=0.99
R2 = 1.00e-09 [fixed] S=0.000 [negligible - element may be omitted]
Names are the parameter type with its count from the left of the expression,
from 0: in R(10) - (R(100) - Ws(50, 1) | C(1e-6)) the series resistor is
R0, the charge-transfer resistor R1, the Warburg R_W0 and τ_W0, the
capacitor C0; the JSON label field uses the same names. Names follow
positions, not processes: two arcs in series are interchangeable, and a fit
may return them in either order (R1 = 452 Ohm where the start had 60) - also
for the same spectrum fitted with another --weighting or another DE run
(without --seed) - so match arcs between fits by their time constants, not
by name. +/- is one standard error from the
covariance scaled by the residual variance, s^2 = RSS/(n - p); the 95 % CI
uses the t-distribution, in log space for positive scale parameters (R, C, Q,
tau), so it is asymmetric there. Both assume random residuals (see below).
Below the parameters, every (R|C) and (R|Q) arc (G = 1/R in place of R
included) gets its time constant - tau = R*C, or (R*Q)^(1/n) for a CPE -
with a standard error from the covariance and the frequency 1/(2 pi tau) where
its -Z'' peaks. With a parameter at a bound, or without a covariance, the
stderr is left out and the line says why. An arc is a parallel pair of exactly
one R (or G) and one C or Q: in the example above C0 has the branch
R1 - Ws beside it and gets no line, because the resistance beside it at DC
includes R_W, so R*C would not be the charge-transfer time constant
(R1 * C0 is, by hand). Two or more arcs in series also get the sum of
their resistances (under Arc resistances in series:) with its standard error, R1 + R2 R = ... +/- ..., and
for two the correlation of R1 and R2. Close arcs are correlated near -1:
each R is then poorly determined and the sum is not (the line says the split is less certain than the sum), so the sum is the number to report.
Every (R|C) and (R|Q) pair of the chain counts, also one without a time
constant; with G = 0 the chain blocks DC and no sum is printed.
This is a different question from the standard error. A wide CI has two very
different causes - the parameter is irrelevant, or it is strongly correlated
with another - and the CI alone cannot tell them apart. S can: around 1 the
parameter dominates the impedance somewhere, below 0.01 the element does
nothing and can be dropped. So a parameter with high S and a wide CI is
genuinely entangled with another one, while a low S simply means dead weight.
Two things worth knowing before reading the number. S is measured against the
impedance of the whole circuit, so a small series resistor next to a large arc
scores low even when it is perfectly well determined; and the maximum runs only
over the measured frequencies, so an element whose feature lies outside your
range scores low by construction. Values above 1 are normal for parameters that
enter non-linearly, such as a CPE exponent: they mean the parameter matters,
but the size is no share of |Z| and does not rank it against the others
(doc/WEIGHTING_AND_STATISTICS.md 3.7).
Note: "significance" also appears in the residual diagnostics below, where
it means the p-value of a trend. That is a statement about the fit; S is
about a parameter.
Are the residuals noise? The fit error says how large the residuals are,
not what shape they have, and a model missing an element can still fit to a
few percent while its residuals march smoothly across the spectrum. Both
--circuit and --voigt-chain therefore end with a residual line:
Fit error: 4.35% (rel), 322.63 Ohm (abs)
Quality: Acceptable (<5.0%)
Fit vs. noise: 3.96% vs 4.84% (Lin-KK, same points), ratio 0.8 (limit 2)
Residuals: rho1 = +0.91 / +0.90 (Re/Im), runs p = 1.9e-13 / 3.1e-14
! Residuals are not random (Re/Im, 7.1 decade window):
! runs test: p = 1.9e-13 / 3.1e-14 (fails below 0.01)
! trend: +0.65 / -0.11 per decade, span 4.6 / 0.8 (p = 1.1e-30 / 2.1e-01)
! structure: amplitude 0.84 / 0.22 (power 0.90 / 0.48, noise < 0.2)
! A trend means an element is missing or of the wrong type. Structure left
! after it means the right elements, too few of them - the residual plot
! shows where.
! Isolated bad points fail it too: if the per-point residual check lists
! points, refit without them before changing the model.
! The +/- and CIs above assume random residuals; with these they are too
! narrow.
rho1 is the lag-1 autocorrelation, ~0 for independent residuals and near 1
when neighbouring points share a systematic offset. runs p is a runs test
against the median: the p-value that this many sign changes could come from
independent residuals. It alone raises the warning (below 0.01 in Re or Im),
which repeats it on its first line; trend and structure below only describe
the shape, so both can come out insignificant. A quality of Good alongside that warning is not a
contradiction - the fit is close, and still the wrong model. A few isolated
bad points fail the runs test as well, while rho1 stays near 0; the
per-point residual check of the validation then lists them, and a fit
without them is the repair, not another element.
Fit vs. noise compares the size of the misfit with what the data resolve.
Both the circuit fit and Lin-KK are turned into a noise level by the same
estimator (pseudo chi^2, Yrjana & Bobacka 2024) on the same points, and the
line gives their ratio. A correct circuit lands at about 1 (0.6-1.0 on
synthetic data at 0.1-2 % noise); above 2 the line becomes a warning: the
data resolve structure the model does not have, and its parameters are
biased. On EISPOT-test1.DTA the same R-(R|Q)-(R|Q) fits to 4 %, rated
Acceptable, and is at 9.3x the noise of 0.34 %. Lin-KK noise is only an
upper bound - it contains drift - but so does the circuit fit, and the ratio
cancels it. That is also why the example above can show 0.8 next to
non-random residuals: Lin-KK rates that spectrum poor, and the circuit
misfit is the drift every KK-compliant model leaves. The noise is taken from
the Lin-KK run of the full spectrum, restricted to the --f-min/--f-max
points and without those --exclude-flagged leaves out. Leaving bad points
out lowers that noise, but not to the data's: the Lin-KK chain was fitted with
them and keeps part of their pull on the neighbours. Under --fit-on zhit and --fit-on all the line says not compared: the fit runs on the Z-HIT reconstruction, whose own error (up to
~3 % around relaxations) the Lin-KK noise of the measurement does not
describe, and a correct circuit would land at 2-5 or near 0 depending on the
drift. With --no-kk the line is absent; --voigt-chain does not print it
(it is a Lin-KK model itself).
The cancellation fails when a few isolated bad points carry most of the noise: they enter the Lin-KK noise and the circuit fit alike, and the fit they pull still lands at ~1. The KK block then warns that the noise estimate is dominated by a few points, with the estimate from the median residual, when the two differ by more than a factor 2, and the per-point check lists the points that stand out individually (a smooth misfit at one end raises the warning without single points).
The shape is reported as two measurements rather than as one verdict, because residuals commonly carry both at once and each asks for a different repair:
- trend - the slope of a straight line through the residuals, in weighted
units per decade, with
spanits total change across the window andpits significance. A real trend means an element is missing or is of the wrong type. TryQinstead ofC, or add a Warburg. - structure - how much shape is left once that line is removed, as an
amplitudein the same weighted units, with the periodogrampoweras its significance (white noise stays below 0.2). Real structure means the circuit has the right kind of elements but too few of them. Add another parallel branch; the residual plot shows which frequencies are unaccounted for.
span and amplitude are in the same units, so comparing them says which
shape dominates - the example above is mostly a drift with structure riding on
it. Neither line is a verdict: read the p and the power and act on
whichever is real, or on both.
This is also the assumption AIC and BIC are built on, so a warning here means the comparison table is ranking circuits that are all inadequate. Fix the model before reading it.
New to this line? doc/RESIDUAL_DIAGNOSTICS.md is the intuitive introduction: two worked repairs, and the limits.
Detailed documentation: doc/CIRCUIT_PARSER.md, doc/K_ELEMENT_GUIDE.md, doc/WEIGHTING_AND_STATISTICS.md (weighting, fit error, standard errors and confidence intervals)
Automatic Voigt chain: R_i at fixed, log-spaced tau_i by linear regression. The linear result is the fit; no nonlinear refinement follows.
The chain is a smooth KK-compatible description of the spectrum, not a model of its processes: --voigt-auto-M stops at the first M where mu drops below the threshold (negative R_i, over-fitting), so M follows the noise and the point density, and one process spreads over several neighbouring elements (two arcs: 22 elements, 12 after pruning). Read R_s and R_pol (sum of R_i) from it; for the number of processes use the DRT peaks (a lower bound) and compare fitted circuits.
# Basic Voigt chain
eis data.DTA --voigt-chain
# Automatic element count optimization
eis data.DTA --voigt-chain --voigt-auto-M
# Custom density (elements per decade)
eis data.DTA --voigt-chain --voigt-n-per-decade 5Detailed documentation: doc/VOIGT_CHAIN_MATH.md
Global optimization for finding global minimum.
# Default DE
eis data.DTA --circuit "R(100) - (R(5000) | C(1e-6))"
# Custom parameters
eis data.DTA --circuit "..." --de-strategy 2 --de-popsize 20 --de-maxiter 500DE strategies: 1=randtobest1bin, 2=best1bin, 3=rand1bin (default)
Archive check: DE keeps only its best point, so when its population
collapses into a wrong basin the right one - visited in the early
generations - is lost. Every evaluation is therefore recorded; after DE the
best distinct point of each of 30 windows over the first half of the run is
refined like the DE result, and the best refined point wins (DE ended in a local minimum ... when that is another model). A refined point with a
different spectrum that fits statistically as well gives Ambiguous model ... with its parameters (only for a fit below 5 % error: when the model
does not fit, its residuals are not noise and the test turns lenient). On 200 benchmark fits this repaired 39 of the 40
that DE got wrong, with no false warning, for ~1 s against 10-45 s of DE.
Works with any --de-workers; --no-archive-check switches it off.
Detailed documentation: doc/DIFFERENTIAL_EVOLUTION.md
Multiple starts from different initial points.
# Multi-start with 20 restarts
eis data.DTA --circuit "..." --optimizer multistart --multistart 20
# With larger perturbation
eis data.DTA --circuit "..." --optimizer multistart --multistart-scale 3.0Detailed documentation: doc/MULTISTART_OPTIMIZATION.md
Oxide layer thickness calculation from capacitance, or the inverse - relative permittivity from an independently known thickness.
# Automatic analysis
eis data.DTA --circuit "..." --analyze-oxide
# Custom permittivity and area
eis data.DTA --circuit "..." --analyze-oxide --epsilon-r 9 --area 0.5
# Inverse: thickness known from SEM/TEM, estimate permittivity
eis data.DTA --circuit "..." --analyze-oxide --thickness 25Common permittivities: ZrO2 ~ 22, Al2O3 ~ 9, TiO2 ~ 80, SiO2 ~ 3.9
The inverse direction is useful for cross-validation: if the estimated permittivity lands near the literature value for the expected oxide, the chosen equivalent circuit is physically consistent.
Detailed documentation: doc/OXIDE_ANALYSIS_GUIDE.md
Shows where, and how, a layer's dispersion departs from one CPE - without a circuit fit.
eis data.DTA --local-exponentFor a layer with admittance Y = G + Q(j omega)^n + j omega C, the real part
of the admittance (R_inf and L from --ri-fit's estimator subtracted) is
G + Q omega^n cos(n pi/2). C does not enter it, and where the CPE dominates,
its log-log slope is n. The map is that slope, from a sliding one-decade
regression:
- one CPE: flat at its n;
- DC conductance G: n falls towards 0 at low frequency;
- a blocked transport channel (e.g.
Wa,Wo): n rises towards that channel's own exponent at low frequency; - anything else that varies: more than one dispersive process.
A point counts as determined when its uncertainty - the regression stderr combined with the shift of n when R_inf moves by 5 % - is at most 0.02; near f_max, where Re Z approaches R_inf, points drop out for that reason. A span of n above 0.1 over the determined points raises a warning. Spectra without a blocking layer (several closed arcs) give n near 0 and little information.
-
input- Input file (.DTA or .csv). Without argument, synthetic data is used for testing. Built-in test circuit: Rs - (R0||Q0) - (R1||Q1) with 1% Gaussian noise:- Rs = 10 Ω (series resistance)
- R0 = 100 kΩ, Q0 = (1e-6 S·s^n, n=0.6)
- R1 = 800 kΩ, Q1 = (3e-5 S·s^n, n=0.43)
-
--f-min- Minimum frequency [Hz]. Data below this value will be cut off. Useful for removing noise at low frequencies. -
--f-max- Maximum frequency [Hz]. Data above this value will be cut off. Useful for removing artifacts at high frequencies. -
--exclude-flagged {kk,both,any}- Leave the points the per-point residual check lists (see--max-residual) out of everything after validation - R_inf, DRT, Voigt analysis, circuit fit, oxide analysis - the way--f-min/--f-maxleave a range out; validation itself still runs on the full spectrum.kk(recommended) takes the points Lin-KK flagged,boththose flagged by Lin-KK and Z-HIT,anyevery listed point; only points inside--f-min/--f-maxcount.kkandbothneed Lin-KK (not with--no-kk),bothalso Z-HIT (not with--no-zhit). The run lists the points left out, warns when some lie in the lowest decade of the analysis range (there they are often drift, and raising--f-minis the honest cut), says so when the check it needs did not run, and--saverecords them in the fit JSON (context.excluded_frequencies). Every point at a listed frequency goes, also a repeated one.Both cuts apply to R_inf estimation, DRT and circuit fitting only. Kramers-Kronig and Z-HIT always run on the full measured spectrum: they are integral relations over all frequencies, so validating a truncated range produces spurious residuals.
Independently of these options, points with Re(Z) < 0 at the high-frequency end are dropped on loading (
load_data,load_csv_data), before validation. No passive system has Re(Z) < 0; at the top of a sweep it is a lead artifact that breaks Lin-KK, the DRT and the fit and makes R_inf negative. Only the contiguous run from the highest frequency is removed: Re(Z) < 0 elsewhere can be a real negative resistance (passivation, oscillating systems under DC bias), so it is kept with a warning.
--circuit,-c- Equivalent circuit for fitting. Syntax:-= series,|= parallel. Example:"R(100) - (R(5000) | C(1e-6))". Supported elements: R, C, L, G, Q, W, Ws, Wo, Wa, Wat, K, GE, CC, DQ, YG. Repeat the option to fit several candidates on the same data and rank them by AIC/BIC - see Comparing candidate circuits.--weighting(default: modulus) - Weighting type for fitting:uniform(w=1, all points equal),sqrt(w=1/sqrt|Z|, compromise),modulus(w=1/|Z|, balances relative errors),proportional(w=1/|Z|^2, emphasizes low-|Z| points, the high-frequency end of a typical spectrum). See doc/WEIGHTING_AND_STATISTICS.md for detailed guide.--no-fit- Skip circuit fitting.
--optimizer(default: de) - Optimizer type:de(Differential Evolution - global),multistart(multiple local fits), orsingle(one local fit).
Parameters whose bounds span many decades (R, C, Q, L) are searched as
log10(value), so the population spreads over the decades instead of being
drawn almost entirely from the top one; the exponents (n of the CPE and
alpha of the Cole-Cole element) stay linear.
If DE still ends far from the data and only the local refinement gets there,
the fit reports Global search contributed nothing - see
doc/DIFFERENTIAL_EVOLUTION.md section 7.3.
--de-strategy(default: 3) - DE strategy: 1=randtobest1bin (faster), 2=best1bin (fastest), 3=rand1bin (default). The first two pull the population toward the current best member and, on 140 benchmark fits, ended in a wrong local minimum 5 % of the time (a fit with two time constants 4 times in 20); rand1bin never did, for about twice the DE time.--de-popsize(default: 15) - Population size as multiple of parameter count. Higher = better exploration but slower.--de-maxiter(default: 1000) - Maximum number of generations. Increase if optimization doesn't converge.--de-tol(default: 0.01) - Convergence tolerance (relative fitness change).--de-workers(default: 1) - Number of parallel workers. -1 = all CPU cores.--seed N- Seed for the random search of DE and of multi-start (--multistart): the same seed and data give the same fit. Without it every run searches differently, so a fit with several near-equal minima can come back with different values. It seeds only the search: the synthetic demo data (no input file) stay unseeded. Not allowed with--optimizer singleor--voigt-chain, which draw no random numbers.--no-archive-check- Skip the DE archive check (refining early-generation candidates against local minima and ambiguous models, ~1 s per fit).
--multistart N- Number of restarts for multi-start optimization (default: 16). Each restart starts from perturbed initial values. Implies--optimizer multistart; combining with another--optimizeris an error.--multistart-scale(default: 2.0) - Perturbation size in sigma units (standard deviation from covariance matrix). Higher = larger parameter space exploration.
--lambda,-l(default: auto GCV) - Regularization parameter for DRT. Without this parameter, automatic selection using GCV (Generalized Cross-Validation) and L-curve method is used. It weighs the roughness integral of gamma against the mean squared residual, so the same value means the same smoothing for any--n-tauand frequency range; typical values are 1e-9 to 1e-3, higher for noisier data. Higher values = smoother DRT, lower = more detail but also noise. Note: on low-noise data auto-lambda may drive lambda toward 0, giving a sparse/spiky DRT that is unreliable for peak-shape analysis; the tool reports the effective bin count (N_eff) and warns when the DRT is too sparse, when lambda lands at the edge of the search range (1e-10 to 1e-2; a minimum at an end is refined one decade further, so the value may lie down to 1e-11 or up to 1e-1), or when the L-curve corner lands at the edge of its own window, a few decades around the GCV lambda, so the true corner may lie further out — set--lambdamanually (e.g. 1e-6) in that case.--drt-weighting(default: sqrt) - Weighting of the DRT least-squares term:sqrt(1/sqrt|Z|),modulus(1/|Z|, best when noise is proportional to |Z|),proportional(1/|Z|^2) oruniform(unweighted, the behaviour before 0.38). Independent of--weighting, which applies to circuit fitting only.--drt-inductance(default: auto) - Series inductancej*omega*Lin the DRT model:autoadds it when the top frequency decade has a point with Im(Z) > 0,onalways,offnever (the behaviour before 0.41). Without it an inductive high-frequency end deforms gamma.--tau-extend(default: 0) - Extend the tau grid this many decades past the slow end of the measured window, orautoto extend only when that resolves a slow-end pile-up and the low-frequency end is not capacitive. Peaks past the window are marked as extrapolated and the share of R_pol past the window is reported.--n-tau,-n(default: 100) - Number of points on the tau time constant axis across the measured window (an extension adds points at the same spacing). Higher values give finer DRT resolution but increase computational cost.--normalize-rpol- Normalize gamma(tau) by polarization resistance so that integral = 1. Useful for comparing samples with different R_pol.--peak-method(default: scipy) - Peak detection method in DRT:scipy(local maxima kept by the Delta chi^2 significance test, see DRT analysis) orgmm(robust, weighted Gaussian Mixture Model fitted directly to gamma(tau)).--gmm-bic-threshold(default: 10.0) - BIC threshold for GMM peak detection. Lower values detect more peaks (2-5: sensitive, 10-20: conservative). Only used with--peak-method gmm.--lambda-probe- Peak stability diagnostics: re-solves the DRT at lambda10^(+-0.5) and lambda10^(+-1) around the selected lambda and tracks each detected peak across the solutions. Reports per-peak persistence, position drift (decades of tau), R variation, and a verdict (STABLE / MARGINAL / ARTIFACT). A peak that appears only in a narrow lambda window is likely a regularization artifact rather than a real relaxation process. STABLE says a peak is not a regularization artifact, not that it is one process: two processes up to about a decade apart merge into one peak that is stable across the whole probe and stays merged at any lambda (the peak count is a lower bound, see DRT analysis) - fit one element more to check. The probe solutions are also drawn as thin overlay curves in the DRT plot. Probe lambdas are clipped to [1e-11, 1e-1] (the selection range widened by a decade), so a lambda close to a bound yields a sweep narrower than the nominal two decades; the reported span says how much ground a verdict actually covers. Example:eis data.DTA --lambda-probe.--ri-fit- Estimate R_inf by fittingR_s - L - (R|Q)to the top two frequency decades (f >= f_max/100) instead of taking the default median of the (up to 5) highest-frequency points. The inductance absorbs a cabling tail, the (R|Q) element the start of an unclosed (also depressed) arc. The fitted R_s is used only if its standard error is at most 5 %; otherwise R_inf cannot be determined from the data (e.g. an arc lying above f_max) and Re(Z) at f_max, an upper bound, is used with a warning. The CLI prints both values; the chosen one goes to the DRT. See doc/RINF_ESTIMATION.md.--no-drt- Skip DRT analysis. Useful if you only want circuit fitting.
--no-kk- Skip Kramers-Kronig validation. KK test verifies causality, linearity, and stability of data.--mu-threshold(default: 0.85) - Stopping threshold of the Lin-KK M-iteration: elements are added until mu drops below this value. The iteration starts where pseudo chi^2 levels off (printed asfrom M=...), not at M = 3: on a coarse tau grid mu dips below the threshold from discretization alone and would stop with an under-resolved model. Lower values allow more Voigt elements (higher overfit tolerance). It controls the fit, not data quality - quality is judged by the residuals.--auto-extend/--no-auto-extend(default: on) - Automatically optimize extend_decades for KK validation (minimizes pseudo chi-squared). The tau grid is extended below the lowest measured frequency only, which reduces the spurious imaginary-part residuals of a relaxation that continues past it (capacitive low-frequency tail); the inductive high-frequency tail is covered by the series L in the model. On by default. Use--no-auto-extendto disable.--extend-decades-max(default: 1.0) - Maximum extend_decades for--auto-extendsearch range.--kk-series-c- Include a series capacitance1/(jwC)in the Lin-KK model (Schonleberadd_cap). Use for blocking/capacitive low-frequency behavior (e.g. two-electrode cells, blocking oxides): a series C is KK-compliant but has zero real part, so the standard Voigt chain cannot represent it and imaginary residuals grow toward low frequencies while the real fit stays good. Off by default so results stay comparable with earlier analyses; the fitted C is printed in the KK summary. When the test fails without it and the low-frequency end is capacitive (-Z'' still rising), a warning says so: the residuals and the noise estimate then describe the Voigt chain's failure, not the data. The test is therefore repeated with the series C, and the per-point check andFit vs. noise(labelledLin-KK with the series C) use that run. If it passes, the CLI suggests rerunning with--kk-series-cto make it the main result; if it fails as well, a warning says the data violate KK beyond the shape of the end - drift, typically (a drifting phase bends -Z'' upward even on a closing arc) - and the per-point check shows where.
--no-zhit- Disable Z-HIT validation (runs by default alongside Lin-KK).
Reads the per-point residuals of both validations above, so it belongs to neither.
Each validation ends with a Data quality: line. Its label grades the mean residual (for Lin-KK the larger of the real and imaginary means, for Z-HIT the magnitude):
| Mean residual | Label |
|---|---|
| < 0.5 % | excellent |
| < 1.0 % | good |
| < 2.5 % | acceptable |
| < 5.0 % | marginal (check for drift/nonlinearity) |
| >= 5.0 % | poor |
The pass/fail verdict differs between the two. Lin-KK fails when more than 5 % of the points lie above 5 %, and then the label reads poor whatever the mean, because a local violation fails with a good mean. Z-HIT passes on a mean below 5 %, so drift confined to the low-frequency end can pass with marginal; the suspicious-points list below shows where. A failed verdict prints the line as a warning (!).
-
--max-residual(default: 5.0) - Residual threshold for flagging an individual point as suspicious [%]. Applies to both KK and Z-HIT: once the validations have run (either one alone is enough), points whose relative deviation|Z - Z_fit| / |Z|exceeds this value in either method are listed with the frequency, both residuals, and which method flagged them. Each residual plot is then marked with the points its own method flagged - a band on the KK panel at a frequency KK considers fine would contradict theflagged bycolumn. The two methods are complementary - Lin-KK fits both impedance components and is sensitive to phase errors, Z-HIT reconstructs the magnitude from the phase and is sensitive to magnitude errors and drift. Nothing is removed or down-weighted; the list is diagnostic. Higher values = less sensitive.Two guards keep the list meaningful. A point must also exceed four times the median residual of that method, because a residual is the sum of the data error and the method's own reconstruction error, and Z-HIT's is the larger of the two (on measured spectra this changes nothing - the absolute threshold stays binding). And a method whose median residual already exceeds the threshold is skipped entirely - over half its points would be flagged, so the spectrum fails as a whole, which the
Data quality:line already reports. The median rather than the mean: a handful of genuine outliers pulls a mean over the threshold and would switch the report off exactly when it has something to say.On a low-noise spectrum a point a few percent off is many times the noise and pulls a fit, yet stays under 5 %. A Lin-KK point is therefore also listed, whatever
--max-residual, when its residual exceeds 8 times the median Lin-KK residual. Calibrated on stress-test spectra: it finds two thirds of the bad points 20-40 times the noise and 84 % of the larger ones, and flags a clean spectrum in 8 % of cases at 0.1 % noise, 1 % at 0.3 % and none from 1 % on. Those false flags lie at the low-frequency end, where the Lin-KK chain misses the end of a diffusion or oxide spectrum. Off on noise-free synthetic data (median residual under 0.05 %), when Lin-KK uses more elements than half the points (on a sparse grid the chain passes through most points and the median collapses), and for Z-HIT.Interpretation: an isolated point with good neighbours is usually a genuine defect (interference, contact, bubble). A systematic trend toward the lowest frequencies is usually sample drift - a real property of a non-stationary measurement, not a bad point, and deleting it hides the problem instead of fixing it.
Leaving the points out:
--exclude-flagged kkreruns everything after validation without the points Lin-KK flagged, and the check suggests it whenever Lin-KK flags a point above the lowest decade. Lin-KK rather than Z-HIT: Z-HIT rebuilds |Z| from the integrated phase and smears one bad point into its neighbours, so it lists the neighbours of a bad point that Lin-KK lists alone. On close arcs a few bad points can move the split of the polarisation resistance by tens of percent while the fit still reads 1.0x the noise. Points the check does not list stay in, and so does their pull. What to report then (the CLI says it under each fit run with excluded points, and a fit run without them, while Lin-KK flagged points above the lowest decade, says to report the other one): the fit without the points, with its own +/-, naming the frequencies left out (--savewrites them to the fit JSON ascontext.excluded_frequencies). The points fail the KK test the fit rests on, so leaving them out is ordinary outlier rejection, not a choice between two equal results. The difference between the two fits is not an uncertainty - do not quote it as a range or average over it; it is how hard the points pulled. A large shift is still a warning: the check lists only points that stand out on their own, and the ones it misses keep pulling, so a split that moved by more than its standard error is only as good as that list - look at the residual plot for further points, or repeat the measurement. Quantities the shift leaves alone (typically R_s and the sum R1 + R2 of close arcs) are the robust part of the result.
Lin-KK and Z-HIT always validate the full spectrum, whatever --f-min/--f-max say (see Input data and frequency range). The range worth analysing is read from their output and then set by hand:
- Both
Data quality:lines pass and noSuspicious pointstable appears: use the whole spectrum. - In the table (listed by frequency) look for a contiguous run from one end. A run from the low end is drift of a non-stationary sample, one from the high end a lead or instrument artifact. Isolated points with clean neighbours are defects; leave them, the list is diagnostic.
- Find where the run stops in the residual plots (
--save:_kk,_zhit), not in the table: it lists only points above--max-residualand four times the method's median, and lowering--max-residualdoes not reveal more, because the median guard stays binding. A run stops where the drift falls below what that test resolves, not where the drift starts, so its end is a lower bound for the cut. Z-HIT's residual is local and close to the size of the drift, but drift smaller than its own reconstruction error (a few percent) is invisible to it; Lin-KK's run usually reaches higher. Lin-KK is one global fit: past its run it can raise isolated points further up the spectrum - the same drift spread out, not a second bad range. - Cut with
--f-min(--f-maxat the high end) at or above the higher of the two run ends. How far above, the spectrum does not tell: on four synthetic drifts the drift started from 0.1 decade below that end (Re drift of 25 %/decade) to 1.2 decades above it (a phase drift of 5 deg/decade, which no run reached). A repeat measurement locates the start best: where the two spectra part. The KK and Z-HIT sections do not change with the cut; it shows in R_inf, the DRT, the fit and itsFit vs. noiseline. - Where only
|Z|drifts and the phase is sound,--fit-on zhitkeeps the low frequencies instead of cutting them (see Z-HIT as a correction).
Example: a spectrum (10 mHz - 1 MHz) whose Re(Z) drifts by 15 % per decade below 1 Hz. Lin-KK poor (24 of 81 points above 5 %), Z-HIT marginal (mean 4.7 %). The table lists Z-HIT points from 10 mHz (25 %) to 0.25 Hz (9 %). In the plots, the Lin-KK residual stays at 6-10 % from the low end up to 1 Hz and below 5 % above it, except four isolated points of 5-6 % between 0.5 and 2.5 kHz; the Z-HIT residual is back within its own error (below 5 %, median 1.5 %) from 0.4 Hz. The higher run end is Lin-KK's, 1 Hz, and here the drift starts exactly there: with --f-min 1 the fit error of the true circuit drops from 2.9 % to 0.65 %, while the validation sections print what they printed before.
When the two validations disagree:
| What the output shows | Likely cause | What to do |
|---|---|---|
| Both flag a run from the low end; Lin-KK's run usually reaches higher | Drift (non-stationary sample) | --f-min at or above the higher run end, or --fit-on zhit |
| Both flag a run from the high end | Lead or instrument artifact | --f-max |
Lin-KK fails across the spectrum but passes with --kk-series-c, Z-HIT clean |
Capacitive (blocking) low-frequency end the Voigt chain cannot model; the data are fine | --kk-series-c |
Lin-KK fails on a capacitive end and with --kk-series-c as well |
Drift of |Z| or phase; Z-HIT usually shows it as a run from the low end | Treat as drift: --f-min |
| A single point flagged by both | Defect (interference, contact, bubble) | Nothing; Lin-KK may still pass |
A known limit: drift of Re(Z) alone on a capacitive (blocking) low-frequency end can pass both validations. Re(Z) is a small fraction of |Z| there - 130 Ohm of 16 kOhm at 10 mHz in a synthetic blocking cell - so a drift of 15 % per decade below 1 Hz moves |Z| by 0.25 %, below a noise of 0.3 % of |Z|: Lin-KK with --kk-series-c passes, Z-HIT stays under 5 %. It still biases what the phase of that end determines: the CPE exponent of the fitted Q came out 0.891 +/- 0.001 instead of 0.900, while Fit vs. noise stayed at 0.8-0.9 and the residual diagnostics raised nothing. Nothing in one spectrum shows it. When the exponent of a blocking end matters, measure the spectrum twice in a row, or sweep in both directions, and compare.
--voigt-chain- Use automatic Voigt chain fitting: linear regression for R_i at fixed, log-spaced tau_i; the linear result is used as is (no nonlinear refinement).--voigt-n-per-decade(default: 3) - Number of time constants per decade for Voigt chain. Higher = finer coverage but more parameters.--voigt-extend-decades(default: 0.0) - Extend tau range by N decades beyond data limits. Useful if you expect processes outside measured range.--voigt-prune-threshold(default: 0.01) - Threshold for removing small R_i (as fraction of R_pol). Elements with R_i < threshold * R_pol are removed.--voigt-allow-negative- Allow negative R_i values (Lin-KK style). Otherwise negative elements are removed.--voigt-no-inductance- Do not include series inductance L in model.--voigt-fit-type(default: complex) - Fit type:complex(default, real+imag),real(real part only),imag(imaginary part only).--voigt-auto-M- Automatically optimize number of M elements using mu metric. The M search is a Lin-KK fit and allows negative R_i whatever--voigt-allow-negativesays (the CLI printsAllow negative R_i: Truefor it); the final chain follows the flag.--voigt-mu-threshold(default: 0.85) - Mu threshold value for--voigt-auto-M.--voigt-max-M(default: 50) - Maximum number of M elements for--voigt-auto-M.--no-voigt-info- Do not display detailed Voigt chain fitting info.
--analyze-oxide- Perform oxide layer analysis - thickness calculation from capacitance.--epsilon-r(default: 22.0) - Relative permittivity of oxide. Default 22 for ZrO2. Other oxides: Al2O3 ~ 9, TiO2 ~ 80, SiO2 ~ 3.9. Ignored (with a warning) when--thicknessis given.--thickness- Known oxide thickness [nm], e.g. from SEM/TEM. Reverses the analysis: the thickness becomes the input and the relative permittivity the estimated quantity.--area(default: from DTA metadata, else 1.0) - Electrode area [cm^2]. Required for correct thickness calculation. An explicit value always takes precedence over the DTA metadata.--local-exponent- Map the local CPE exponent n(f) from the real part of the admittance; needs no circuit fit. Prints n per decade, min/max/span, and warns when the span exceeds 0.1. See Local CPE exponent n(f).--rho-delta- Film resistivity at the electrolyte interface [Ohm cm]. Adds the power-law (Hirschorn-Orazem) thickness, or permittivity with--thickness, as a comparison value for a dominant CPE. No default: the spectrum does not determine it. Seedoc/OXIDE_ANALYSIS_GUIDE.md.
--fit-on(default: original) - Which data the fit runs on.originalis the measurement.zhitfits the circuit against the Z-HIT reconstruction of|Z|from the phase, leaving R_inf, DRT and the Nyquist/Bode plot on the measurement.allsubstitutes the reconstruction for every stage below validation. Corrects drift of the low-frequency modulus; cannot be combined with--no-zhit, which is the source of the reconstruction.
--numeric-jacobian- Use numeric Jacobian instead of analytic. Analytic Jacobian is faster and more accurate but not available for all elements. Use this option for custom/non-standard elements.
--ocv- Display OCV (Open Circuit Voltage) curve if available in data.--save,-s- Save plots with this prefix; its directory is created if missing.--save resultswritesresults_nyquist_bode,results_kk,results_zhit,results_drt,results_fitand, when the matching switch is given,results_ri_fit(--ri-fit),results_ocv(--ocv) andresults_thd(Gamry files recorded with THD), each with the extension given by--format. Only the plots actually produced by the run are written. A circuit fit also writesresults_fit.json(fitted circuit expression, parameters with stderr, 95% CI and significance, the time constants of the (R|C) and (R|Q) arcs and the sum of the arc resistances, fit metrics with AIC/BIC, covariance in the order of the parameters (see doc/PYTHON_API.md, Fit result export, for the uncertainty of a sum like R1 + R2), warnings, and how the fit was run) andresults_fit.csv(freq_Hz, Z_real_Ohm, Z_imag_Ohm, Z_fit_real_Ohm, Z_fit_imag_Ohm, loadable back as input); under--fit-on zhitand--fit-on alltheZcolumns hold the Z-HIT reconstruction the fit ran on, not the measurement. Comparison runs (several--circuit) write one pair per candidate,results_fit_1.*,results_fit_2.*, ... With--analyze-oxide,results_oxide.jsonholds the oxide result: thickness (or permittivity with--thickness) with the Brug and power-law values where computed, the capacitance, the dominant element with its parameter label inresults_fit.json, epsilon_r, area and warnings.--format,-f(default: png) - Format of saved plots:png(raster),pdf/svg/eps(vector for publications).--no-show- Do not display plots interactively. Useful for batch processing with--save.-v,--verbose- Show debug messages on stderr (prefix[DEBUG]).-q,--quiet- Quiet mode - hide INFO messages, show only warnings and errors.--version- Print the version and exit.
Logging levels:
- INFO (default): stdout, no prefix
- WARNING: stdout, prefix
! - ERROR: stderr, prefix
!! - DEBUG (
-v): stderr, prefix[DEBUG]
For an isolated environment, we recommend using a virtual environment (venv). This prevents dependency conflicts with other projects and does not affect your system Python installation. If you prefer not to use venv, skip to the direct installation below.
With virtual environment (recommended):
Linux/macOS:
# Create virtual environment
python3 -m venv eis_env
# Activate environment
source eis_env/bin/activate
# Install (now in isolated environment)
git clone https://github.com/chmelat/eis_analysis
cd eis_analysis
pip install -e . # Editable install (for development)
# or
pip install . # Standard installWindows: see the dedicated step-by-step guide in Installation on Windows.
Without virtual environment (direct installation):
git clone https://github.com/chmelat/eis_analysis
cd eis_analysis
pip install -e . # Editable install (for development)
# or
pip install . # Standard installAfter installation, the eis command is available (in activated environment if using venv):
eis --help
eis data.DTAStep-by-step guide, no prior Python experience needed.
1. Install prerequisites (one-time setup):
- Python - download from python.org/downloads and run the installer. Important: check "Add python.exe to PATH" on the first installer screen.
- Git (optional) - download from git-scm.com. If you don't want to install Git, download the project as a ZIP instead: on the GitHub page click Code -> Download ZIP and extract it.
2. Open PowerShell (Start menu -> type "PowerShell") and get the project:
git clone https://github.com/chmelat/eis_analysis
cd eis_analysis(If you downloaded the ZIP, use cd to enter the extracted folder instead.)
3. Create and activate a virtual environment (recommended - keeps the installation isolated):
python -m venv eis_env
eis_env\Scripts\Activate.ps1If activation fails with an error about scripts being disabled, allow local scripts first (one-time setting), then retry:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserIf you use the classic Command Prompt (cmd.exe) instead of PowerShell, activate with eis_env\Scripts\activate.bat.
4. Install and run:
pip install .
eis --help
eis data.DTAThe (eis_env) prefix in the prompt shows the environment is active. When you open a new PowerShell window later, activate it again with eis_env\Scripts\Activate.ps1 before running eis.
Note: If you install without a virtual environment and Windows reports eis is not recognized, the Python Scripts folder is not in your PATH (pip prints a warning about this during installation). Either use the virtual environment as shown above, or run the tool as python eis.py from the project folder.
If you prefer not to install the package, you can run the script directly:
pip install numpy scipy matplotlib # Install dependencies
python3 eis.py --help # Run script directly
python3 eis.py data.DTAOn Windows, use python instead of python3 (the python3 command does not exist there by default):
python eis.py --helpsudo apt install python3-numpy python3-scipy python3-matplotlib
pip install -e . # Then install the package# Development tools (ruff, mypy, pytest)
pip install -e ".[dev]"Run the test suite with pytest:
python3 -m pytest tests/ # All tests
python3 -m pytest tests/ -v # Verbose output
python3 -m pytest tests/ -q # Quiet mode
python3 -m pytest tests/test_drt_recovery.py # Single file
python3 -m pytest tests/ -k "voigt" # Tests matching patternOn Windows, use python -m pytest instead of python3 -m pytest.
Complete Python API: doc/PYTHON_API.md
| Document | Description |
|---|---|
| doc/PYTHON_API.md | Complete Python API reference |
| doc/WEIGHTING_AND_STATISTICS.md | Weighting types and statistical metrics |
| doc/ROBUST_LOSS_SOFT_L1.md | Robust loss (soft-L1): proposal, rationale and measured benefit - not implemented yet |
| doc/CIRCUIT_PARSER.md | Circuit parser syntax |
| doc/K_ELEMENT_GUIDE.md | K element guide |
| doc/MODEL_SELECTION_AIC_BIC.md | Choosing between circuits (AIC/BIC) |
| doc/RESIDUAL_DIAGNOSTICS.md | Are the residuals noise? - intuitive introduction |
| doc/KK_INTUITION.md | KK validation - intuitive introduction |
| doc/LinKK_analysis.md | Kramers-Kronig validation |
| doc/ZHIT_IMPLEMENTATION_SPEC.md | Z-HIT validation |
| doc/DRT_INTUITION.md | DRT - intuitive introduction |
| doc/GCV_IMPLEMENTATION.md | Lambda selection (GCV + L-curve) documentation |
| doc/GMM_PEAK_DETECTION.md | GMM peak detection |
| doc/DRT_METHOD_ANALYSIS.md | DRT method analysis |
| doc/RINF_ESTIMATION.md | R_inf (ohmic resistance) estimation |
| doc/VOIGT_CHAIN_MATH.md | Voigt chain mathematics |
| doc/DIFFERENTIAL_EVOLUTION.md | Differential Evolution |
| doc/MULTISTART_OPTIMIZATION.md | Multi-start optimization |
| doc/NONLINEAR_FIT_ANALYSIS.md | Nonlinear optimization overview |
| doc/OXIDE_ANALYSIS_GUIDE.md | Oxide layer analysis |
| doc/ZAHNER_ANALYSIS_REVIEW.md | Zahner Analysis manual reviewed for ideas worth adopting - proposals, none implemented yet |
- Orazem, M.E., Tribollet, B.: Electrochemical Impedance Spectroscopy (2008)
- Boukamp, B.A.: "A Linear Kronig-Kramers Transform Test for Immittance Data Validation", J. Electrochem. Soc. 142 (1995)
- Schonleber, M. et al.: "A Method for Improving the Robustness of linear Kramers-Kronig Validity Tests", Electrochimica Acta 131 (2014)
- Yrjana, V., Bobacka, J.: "Implementing Kramers-Kronig validity testing using pyimpspec", Electrochim. Acta 504 (2024)
- Wahba, G.: "A comparison of GCV and GML", Annals of Statistics 13 (1985)
- Saccoccio, M. et al.: "Optimal regularization in DRT", Electrochimica Acta 147 (2014)
This code was developed with the assistance of Claude Code.
License: MIT License
