Electrochemical Impedance Spectroscopy

This example shows you how to perform Electrochemical Impedance Spectroscopy (EIS) measurements with the IM7 potentiostat using the zahner_link library. We’ll cover several common EIS measurement scenarios and demonstrate how to access and visualize the data.

This notebook includes:

  • Basic Setup: Connecting to the IM7 and initializing the potentiostat.

  • Automated EIS Measurement: Running EIS with automatically generated frequency points using EisGenerateJob.

  • Custom EIS Spectra: Creating custom EIS spectra using two approaches:

  • Parameter-Dependent Impedance: Measuring impedance at a single frequency while varying DC bias current in galvanostatic mode (useful for diode characteristic curves).

  • Data Access: Retrieving frequencies, complex impedance values, DC voltage/current from the measurement results.

  • Visualization: Creating plots of the EIS spectra and impedance vs. parameter data.

  • Data Export: Saving measurement data to Zahner XML format for use with other tools.

Basic procedures like DC calibration after warm-up are not covered in this example.

We’ll start by importing the necessary libraries and defining a helper function for plotting EIS spectra.

import matplotlib.pyplot as plt
from matplotlib.ticker import EngFormatter
import numpy as np
import zahner_link as zl


def plot_spectra(data: zl.EisDataset):
    frequencies = data.get_frequencies()
    impedances = data.get_impedance_data().get_calculated_complex_impedance_track()
    fig, (impedance_ax) = plt.subplots(
        1,
        1,
    )
    phase_ax = impedance_ax.twinx()

    (impedance_line,) = impedance_ax.loglog(
        frequencies,
        np.absolute(impedances),
        marker="x",
        color="blue",
        label="Impedance",
    )

    (phase_line,) = phase_ax.semilogx(
        frequencies,
        np.angle(impedances, deg=True),
        marker="x",
        color="red",
        label="Phase",
    )

    impedance_ax.yaxis.set_major_formatter(EngFormatter(unit=r"$\Omega$"))
    impedance_ax.xaxis.set_major_formatter(EngFormatter(unit="Hz"))
    impedance_ax.set_ylabel(r"|Z|")
    impedance_ax.grid(linestyle="dashed", linewidth=0.2)
    margin = 0.01
    impedance_ax.margins(margin)

    phase_ax.yaxis.set_major_formatter(EngFormatter(unit="$°$", sep=""))
    phase_ax.xaxis.set_major_formatter(EngFormatter(unit="Hz"))
    phase_ax.set_ylabel("Phase")
    phase_ax.grid(linestyle="dashed", linewidth=0.2)
    phase_ax.margins(margin)
    impedance_ax.legend(handles=[impedance_line, phase_line])

    fig.set_size_inches(18, 10)
    fig.tight_layout()
    plt.show()


link = zl.ZahnerLinkExc("10.10.253.154", "1994")
error: zl.ErrorObject = link.connect()

if not error:
    print("connected successfully")
else:
    print(f"failed to connect, status: {error.get_error_code_enum()}, message: {error.get_message_formatted()}")

switch_on_job = zl.control.SwitchOnJob(
    potentiostat="MAIN:1:POT",
    coupling=zl.PotentiostatCoupling.POTENTIOSTATIC,
    bias=0,
    voltage_range_index=0,
    compliance_range_index=0,
)
switch_off_job = zl.control.SwitchOffJob(potentiostat="MAIN:1:POT")
link.do_job(switch_on_job)
connected successfully
<zahner_link._zahner_link.ErrorObject at 0x1e6dac81230>

EIS with Automatically Generated Frequency Points

Now we’ll run an EIS measurement in potentiostatic mode (since we’ve already switched the potentiostat on in this mode). We’ll use EisGenerateJob which automatically generates the frequency points across the spectrum.

The measurement sweeps through frequencies in this order: from start_frequency up to max_frequency, then down to min_frequency. For frequency resolution, the system uses points_per_decade_upper above 66 Hz and points_per_decade_lower at min_frequency decreasing below 66 Hz.

The measurement parameters include:

  • pre_waves and pre_duration: Control the settling period before taking measurements

  • meas_waves and meas_duration: Control the actual measurement period

For these parameters, waves specifies the minimum number of sine waves to measure, while duration sets a time limit. The system will always complete the minimum number of waves, and will continue measuring until the duration is reached (if longer than the minimum waves).

Examples:

  • 1 Hz measurement frequency, meas_duration = 9, meas_waves = 3 ➡️ 9 waves are measured

  • 1 Hz measurement frequency, meas_duration = 0, meas_waves = 3 ➡️ 3 waves are measured

This setup is useful because at high frequencies, it’s better to measure by duration, while at low frequencies, controlling the number of waves is more practical.

eis_generate_job = zl.meas.EisGenerateJob(
    bias=0,
    min_frequency=10,
    max_frequency=1e5,
    start_frequency=10e3,
    points_per_decade_upper=8,
    points_per_decade_lower=5,
    pre_duration=0,
    pre_waves=1,
    meas_duration=0.1,
    meas_waves=4,
    amplitude=1e-2,
)
link.do_job(eis_generate_job)
eis_generate_data = link.get_job_result_data(eis_generate_job)
plot_spectra(eis_generate_data)

xml_measurement = zl.xml.Measurement(eis_generate_data)
exporter = zl.xml.ZXmlExporter()
exporter.set_compact_xml(False)
exporter.save_as_file_standalone(xml_measurement, "eis_generate_job.zmx")
../../../../_images/fecedfdf2d0e47c78fe083ae41dd124c94ce108ba14bed324ce6a44dd26502f4.png
0

Custom EIS Spectrum Using Objects

Instead of using automatically generated frequencies, we can manually define the spectrum using EisFrequencyTableJob. This approach allows us to specify different settings for each frequency point, which is particularly useful when you need varying amplitudes for different frequencies as shown in the example below.

eis_table_job = zl.meas.EisFrequencyTableJob(
    bias=0.0,
    spectrum=[
        zl.meas.EisParametersFrequencyTableEntry(
            frequency=10,
            amplitude=10e-3,
            pre_duration=0.1,
            pre_waves=1,
            meas_duration=0.5,
            meas_waves=3,
        ),
        zl.meas.EisParametersFrequencyTableEntry(
            frequency=1e3,
            amplitude=20e-3,
            pre_duration=0.1,
            pre_waves=1,
            meas_duration=0.5,
            meas_waves=3,
        ),
        zl.meas.EisParametersFrequencyTableEntry(
            frequency=100e3,
            amplitude=30e-3,
            pre_duration=0.1,
            pre_waves=1,
            meas_duration=0.5,
            meas_waves=3,
        ),
    ],
)
link.do_job(eis_table_job)
eis_table_data = link.get_job_result_data(eis_table_job)
plot_spectra(eis_table_data)

xml_measurement = zl.xml.Measurement(eis_table_data)
exporter.save_as_file_standalone(xml_measurement, "eis_table_job.zmx")
../../../../_images/9d2ca47edc45110e3f31082b9e2c72e1e4735fb255b80d42e991db8ac81da58d.png
0

Custom EIS Spectrum Using Python Dictionaries

For even more flexibility, EisFrequencyTableJob also accepts Python dictionaries as input. This approach can be combined with Python’s list comprehensions to dynamically generate the measurement points.

In the example below, we create frequency points from predefined lists of frequencies and amplitudes using a list comprehension.

frequencies_to_measure = [10, 1e2, 1e3, 1e4, 1e5]
amplitudes_to_measure = [10e-3, 5e-3, 10e-3, 100e-3, 100e-3]
eis_dict_table_job = zl.meas.EisFrequencyTableJob(
    {
        "bias": 0.0,
        "spectrum": [
            {
                "frequency": freq,
                "amplitude": ampl,
                "pre_duration": 0.1,
                "pre_waves": 1,
                "meas_duration": 0.5,
                "meas_waves": 3,
            }
            for freq, ampl in zip(frequencies_to_measure, amplitudes_to_measure)
        ],
    }
)
link.do_job(eis_dict_table_job)
link.do_job(switch_off_job)
eis_dict_table_data = link.get_job_result_data(eis_dict_table_job)
plot_spectra(eis_dict_table_data)

xml_measurement = zl.xml.Measurement(eis_dict_table_data)
exporter.save_as_file_standalone(xml_measurement, "eis_dict_table_job.zmx")
../../../../_images/f6f7abf808d26ea92108ccb090ef7a1f09c10f3ac4e1f8e6d67b6623c609baad.png
0

Single Frequency Impedance vs Parameter Study

Here we’ll demonstrate how to measure impedance at a single frequency while varying another parameter - in this case, the DC bias current through a pair of anti-parallel diodes.

First, we switch from potentiostatic to galvanostatic mode. Then we create a logarithmically spaced array (np.logspace) of current values (both positive and negative) and measure the impedance at each current setpoint.

By measuring at a single frequency (100 Hz) while sweeping the bias current, we can observe how the impedance changes with the diode’s operating point. This is a powerful technique for characterizing nonlinear devices.

switch_on_job.parameters.coupling = zl.PotentiostatCoupling.GALVANOSTATIC
bias = 0.1
switch_on_job.parameters.bias = bias
link.do_job(switch_on_job)

eis_same_freq_data = zl.EisDataset()
xml_measurement = zl.xml.Measurement()

positive = np.logspace(np.log10(0.001), np.log10(2), 21)  # 21 points from 0.001 to 2.0
negative = -np.flip(positive)  # Mirror the positive values and negate them
biases = np.sort(np.concatenate([negative, [0], positive]))

for bias in biases:
    print(f"impedance bias: {bias:.3f}")
    eis_dict_table_same_freq_job = zl.meas.EisFrequencyTableJob(
        {
            "bias": bias,
            "spectrum": [
                {
                    "frequency": 100,
                    "amplitude": 10e-3,
                    "pre_duration": 0.2,
                    "pre_waves": 1,
                    "meas_duration": 0.5,
                    "meas_waves": 3,
                }
            ],
        }
    )
    link.do_job(eis_dict_table_same_freq_job)

    data = link.get_job_result_data(eis_dict_table_same_freq_job)
    eis_same_freq_data.append(data)
    xml_measurement.append_dataset(data)

exporter.save_as_file_standalone(xml_measurement, "eis_dict_table_same_freq_job.zmx")
impedance bias: -2.000
impedance bias: -1.368
impedance bias: -0.935
impedance bias: -0.640
impedance bias: -0.437
impedance bias: -0.299
impedance bias: -0.205
impedance bias: -0.140
impedance bias: -0.096
impedance bias: -0.065
impedance bias: -0.045
impedance bias: -0.031
impedance bias: -0.021
impedance bias: -0.014
impedance bias: -0.010
impedance bias: -0.007
impedance bias: -0.005
impedance bias: -0.003
impedance bias: -0.002
impedance bias: -0.001
impedance bias: -0.001
impedance bias: 0.000
impedance bias: 0.001
impedance bias: 0.001
impedance bias: 0.002
impedance bias: 0.003
impedance bias: 0.005
impedance bias: 0.007
impedance bias: 0.010
impedance bias: 0.014
impedance bias: 0.021
impedance bias: 0.031
impedance bias: 0.045
impedance bias: 0.065
impedance bias: 0.096
impedance bias: 0.140
impedance bias: 0.205
impedance bias: 0.299
impedance bias: 0.437
impedance bias: 0.640
impedance bias: 0.935
impedance bias: 1.368
impedance bias: 2.000
0

Switch Off and Disconnect

Now that we’ve completed our measurements, let’s properly turn off the potentiostat and disconnect from the IM7.

link.do_job(switch_off_job)
link.disconnect()

Accessing EIS Measurement Data

The zahner_link library provides a comprehensive API for accessing EIS measurement data. The core classes include:

  • EisDataset: The main container for EIS measurement data

  • ImpedanceData: Provides access to impedance values and related calculations

  • PathData: Allows access to DC and AC tracks for voltage, current, etc.

  • PotentiostatData: Contains metadata about potentiostat settings during measurement

Here’s how to access key data from our measurements:

Let’s look at some of this data from our dictionary-based EIS measurement:

importer = zl.xml.ZXmlImporter()
xml_measurement = importer.import_from_file_as_measurement("eis_dict_table_job.zmx")
data = xml_measurement.get_datasets()[0]

frequencies = data.get_frequencies()
print(frequencies)
[10.0, 100.0, 1000.0, 10000.0, 100000.0]
impedances = data.get_impedance_data().get_calculated_complex_impedance_track()
print(impedances)
[(966.5532911809729-141.4545468038551j), (430.8265309248285-283.77921822818485j), (248.77769251960405-134.2294617906351j), (60.04985931350918-50.790929222211055j), (50.21076858864579-5.450270929780021j)]
periods = data.get_periods()
print(periods)
[4.0, 16.0, 18.0, 57.0, 194.0]
times = data.get_times()
print(times)
[1.156000000017229, 2.25, 3.463999999978114, 4.744000000006054, 5.678000000014435]

Accessing DC Values During Impedance Measurement

In addition to AC impedance data, the IM7 also records the DC values during each measurement. This is particularly important when characterizing nonlinear systems or when operating near changing conditions.

Note that when measuring current, the system uses the same shunt that measures AC current, not a separate DC path. Therefore, the DC data of an impedance measurement may have different offsets than those of a polarization measurement, for example.

To access DC data, use the get_path_data() method to get a PathData object for a specific measurement channel (like “voltage” or “current”). Then call get_dc_track() on that object to get the DC values for each measurement point:

print("voltages per impedance:")
print(eis_same_freq_data.get_path_data("voltage").get_dc_track())
voltages per impedance:
[-1.7406610019631206, -1.6967454859534408, -1.6593485996957706, -1.6255561623375805, -1.5948991498512544, -1.5644920801674513, -1.5356358115397228, -1.5065123320679485, -1.4775488511256174, -1.4484877461082097, -1.4190617152521616, -1.3886161777532247, -1.3565663679704634, -1.3217289820355005, -1.2780292982794061, -0.8755031388693314, -0.5404848303298195, -0.3600465589099588, -0.24539583886169386, -0.16934238911307875, -0.11802485750266731, -0.008135696241483876, 0.1014694406706052, 0.15268481149898352, 0.2283956155608371, 0.3421406231707293, 0.5202638410990048, 0.84428317753347, 1.2748764018476222, 1.3200172662410332, 1.3562025928461348, 1.3891497110755413, 1.42045597621669, 1.4508542430387963, 1.4806605230639949, 1.5103768195294491, 1.5401492999591826, 1.5699301515597794, 1.600225250008227, 1.6313416577643298, 1.664393094673756, 1.6994757456874716, 1.7374257511294067]
print("currents per impedance:")
print(eis_same_freq_data.get_path_data("current").get_dc_track())
currents per impedance:
[-2.003348644367738, -1.3720638354837809, -0.9394463532853371, -0.6437573987992073, -0.4377081221077309, -0.2994473843195491, -0.20457361964732962, -0.1399110748221791, -0.09569268537217607, -0.06545587919665474, -0.044814653069153935, -0.03067692790583856, -0.021007605294520233, -0.014367445061898545, -0.009845663392620194, -0.006747098303299064, -0.0046352187743481585, -0.003190372032113498, -0.002202603456512473, -0.0015273715690929009, -0.0010662401500777289, -6.632413503098334e-05, 0.0009319792769202007, 0.0013941025421192414, 0.002069868451679704, 0.0030577118070980163, 0.00450186742820468, 0.006614149634526162, 0.009713550521302619, 0.014234879797660626, 0.020850462579196247, 0.030489599646690044, 0.04462915770414144, 0.06530499870547134, 0.09557831801290546, 0.1397961834918442, 0.20445717330152702, 0.2989995239075518, 0.43688814070476045, 0.6365354658850269, 0.9313134378517401, 1.3637918201233505, 1.9960145454527167]

Analyzing Impedance vs Parameter Data

Now let’s extract and display the data from our impedance vs. parameter study. We’ll extract the complex impedance values, DC voltages, and DC currents from the accumulated dataset.

impedances = (
    eis_same_freq_data.get_impedance_data().get_calculated_complex_impedance_track()
)
voltages = eis_same_freq_data.get_path_data("voltage").get_dc_track()
currents = eis_same_freq_data.get_path_data("current").get_dc_track()

print(f"voltage; current; impedance")
for z, u, i in zip(np.abs(impedances), voltages, currents):
    print(f"{i}; {u}; {z}")
voltage; current; impedance
-2.003348644367738; -1.7406610019631206; 0.06417168759456064
-1.3720638354837809; -1.6967454859534408; 0.08103459127194487
-0.9394463532853371; -1.6593485996957706; 0.10735300066297851
-0.6437573987992073; -1.6255561623375805; 0.144055099449933
-0.4377081221077309; -1.5948991498512544; 0.1982372761769647
-0.2994473843195491; -1.5644920801674513; 0.27647876481693173
-0.20457361964732962; -1.5356358115397228; 0.3931290102410047
-0.1399110748221791; -1.5065123320679485; 0.5644110138133601
-0.09569268537217607; -1.4775488511256174; 0.8210229268545549
-0.06545587919665474; -1.4484877461082097; 1.2061522876496238
-0.044814653069153935; -1.4190617152521616; 1.7879030993364784
-0.03067692790583856; -1.3886161777532247; 2.677205803628438
-0.021007605294520233; -1.3565663679704634; 4.0660846831525985
-0.014367445061898545; -1.3217289820355005; 6.352968305733535
-0.009845663392620194; -1.2780292982794061; 10.878497539485485
-0.006747098303299064; -0.8755031388693314; 102.22271727012372
-0.0046352187743481585; -0.5404848303298195; 163.32198483308085
-0.003190372032113498; -0.3600465589099588; 185.59620531461977
-0.002202603456512473; -0.24539583886169386; 195.3314799438048
-0.0015273715690929009; -0.16934238911307875; 199.7988883025479
-0.0010662401500777289; -0.11802485750266731; 201.90026208256123
-6.632413503098334e-05; -0.008135696241483876; 203.86522434189513
0.0009319792769202007; 0.1014694406706052; 202.37568698583388
0.0013941025421192414; 0.15268481149898352; 200.51981242095306
0.002069868451679704; 0.2283956155608371; 196.4130627670022
0.0030577118070980163; 0.3421406231707293; 187.26116713582394
0.00450186742820468; 0.5202638410990048; 166.08332076474682
0.006614149634526162; 0.84428317753347; 108.67067468070718
0.009713550521302619; 1.2748764018476222; 11.229236748558883
0.014234879797660626; 1.3200172662410332; 6.483130496252511
0.020850462579196247; 1.3562025928461348; 4.130606117481426
0.030489599646690044; 1.3891497110755413; 2.7134226874601626
0.04462915770414144; 1.42045597621669; 1.809746558343763
0.06530499870547134; 1.4508542430387963; 1.2193134442871414
0.09557831801290546; 1.4806605230639949; 0.8293399420762461
0.1397961834918442; 1.5103768195294491; 0.5694579993446643
0.20445717330152702; 1.5401492999591826; 0.39566456305316294
0.2989995239075518; 1.5699301515597794; 0.27828944371598185
0.43688814070476045; 1.600225250008227; 0.19861404830844076
0.6365354658850269; 1.6313416577643298; 0.14577686582667107
0.9313134378517401; 1.664393094673756; 0.10732970495912862
1.3637918201233505; 1.6994757456874716; 0.0801221009737081
1.9960145454527167; 1.7374257511294067; 0.061379239892580435

Visualizing the Parameter Study Results

Finally, let’s create a plot that shows both the impedance magnitude (on a logarithmic scale) and voltage (on a linear scale) as functions of the bias current. This visualization helps us understand how the diode’s impedance and voltage drop change with current.

fig, (impedance_ax) = plt.subplots(
    1,
    1,
)
voltage_ax = impedance_ax.twinx()

(impedance_line,) = impedance_ax.semilogy(
    currents, np.absolute(impedances), marker="x", color="red", label="|Z|"
)
(voltage_line,) = voltage_ax.plot(
    currents, np.absolute(voltages), marker="x", color="blue", label="Voltage"
)

impedance_ax.yaxis.set_major_formatter(EngFormatter(unit=r"$\Omega$"))
impedance_ax.xaxis.set_major_formatter(EngFormatter(unit="$A$"))
impedance_ax.set_ylabel(r"|Z|")
impedance_ax.yaxis.label.set_color("red")
impedance_ax.set_xlabel(r"Current")

voltage_ax.yaxis.set_major_formatter(EngFormatter(unit=r"$V$"))
voltage_ax.xaxis.set_major_formatter(EngFormatter(unit="$A$"))
voltage_ax.set_ylabel(r"Voltage")
voltage_ax.yaxis.label.set_color("blue")
voltage_ax.set_xlabel(r"Current")

impedance_ax.grid(which="both")

impedance_ax.margins(0.01)
voltage_ax.margins(0.01)
voltage_ax.legend(handles=[impedance_line, voltage_line])

fig.set_size_inches(18, 10)
fig.tight_layout()
plt.show()
../../../../_images/19a664aab357a0458ec2a8e4a2c62aecd5e1623b2a8cbe168f5050714bf1a846.png