Getting Started with Python
This page is a starting point for controlling your Zahner IM7 with Python and the zahner_link library.
It is not a programming course. We will not teach you how to program, others do that far better than we could, and there is a wealth of excellent, free material available for exactly that purpose. Instead, this page points you to the right resources and shows you how to take your first steps, whether you have written code before or not.
Why Python
Zahner uses Python for instrument integration because it is beginner-friendly, free to use, and supported by a large, helpful community. For scientific work it is especially attractive thanks to mature packages such as NumPy, SciPy, and Matplotlib, which let you process and plot measurement data in a way similar to MATLAB or Origin.
Learning Python
If Python is new to you, start with the official beginner’s page:
https://www.python.org/about/gettingstarted/
It explains how to install Python on your computer and links to guides and tutorials that walk you through the basics step by step. Take your time here - a little familiarity with the language goes a long way before you start automating measurements.
Choosing an Editor
To write and run your code, we recommend Visual Studio Code. It is free, widely used, and works well for scientific Python development.
Install the official Python extension to get features such as syntax highlighting, code completion, and an integrated way to run your scripts.
Starting with zahner_link
Once Python is installed and your editor is ready, you can begin with the zahner_link library, which controls your Zahner IM7 instruments from Python. You can easily install the package from PyPI using pip (https://pypi.org/project/zahner-link/):
pip install zahner-link
If you are new to pip, the official Python packaging guide explains the basics:
Installing packages covers the general requirements and preparations for installing any package.
Use pip for installing explains how to run pip specifically.
With the library installed, two resources work hand in hand:
The complete Python API documentation in this documentation describes every available job and function: zahner_link.
Our zahner_link GitHub repository provides ready-to-run examples.
Starting with Examples on GitHub
We recommend starting with the Basic Introduction, which explains the connection setup and the most fundamental concepts in detail. The same examples are also included in this documentation, see Examples.
The examples are provided as Jupyter notebooks.
Notebooks are not meant for writing your actual measurement programs - we use them here only because they present code, its output, and explanatory links neatly together in one place.
For your own development, write plain Python scripts (.py files).
When you start out, it is best to develop your measurement parameters - for example for EIS - in the graphical user interface first. In the GUI it is easier to stop a running measurement and restart it with adjusted parameters until the results look right. Once you have found suitable parameters, you can transfer them into your Python script.
Starting with Generated Code
If you would rather not write everything from scratch, there is a convenient alternative: the code generation feature of the Custom Experiment Builder. You assemble your experiment visually from drag-and-drop blocks, and the builder generates the corresponding Python code for you, which uses the zahner_link library. This is a great way to get a working script quickly and to see how a complete measurement translates into code that you can read, run, and adapt.
It is also a good way to learn the library. Even without prior programming experience, you will recognize the basic building blocks in the generated code:
you connect to the instrument, create a job for each step of your experiment (for example switching the potentiostat on, measuring an impedance spectrum, or switching it off), and execute it with link.do_job(...).
The measured data is collected in datasets and finally saved to disk. Once you have seen this pattern a few times, the API documentation becomes much easier to follow.
Keep in mind that the generated code is a starting point, not a finished application. It gives you a solid, foundation that you can extend - for example by looping over parameters, adding data analysis, or integrating it into a larger workflow.
A typical use case is recording impedance spectra (EIS) at several DC currents, for instance to characterize how a sample behaves at different operating points. In the Custom Experiment Builder you set up a galvanostatic EIS measurement and let it run over a list of currents, then export the generated Python code. The screenshot below shows the corresponding block setup:
The generated code is shown below. Two things are taken care of for you, so you can focus on the actual measurement.
The hardware initialization is generated directly from your setup in the Zahner Lab, so the instrument is configured exactly as you left it - no need to look up channels, ranges, or impedance settings by hand.
Recurring tasks such as handling stop conditions and collecting the data into datasets are provided by the CustomExperimentHelpers class, which you simply import from the client library (see the first line of the code).
This keeps the generated script short and lets you concentrate on the interesting part: the measurement loop at the bottom, where a job is created and executed for each DC current.
import numpy as np
from zahner_link.custom_experiment_helpers import CustomExperimentHelpers
import zahner_link as zl
link = zl.ZahnerLinkExc("10.10.253.150", "1994")
link.connect()
try:
# Hardware Initialization Taken Over by the Zahner Lab
CHANNELS = [
zl.Channel(uri="MAIN:1:POT:U~PAD:1:PAD_U", dimension="voltage", unit="V", polynomial=zl.UserPolynomial([0, 1])),
zl.Channel(uri="MAIN:1:POT:I~PAD:1:PAD_I", dimension="current", unit="A", polynomial=zl.UserPolynomial([0, 1])),
]
IMPEDANCE_CONFIGS = [
zl.ImpedanceConfiguration(numerator="MAIN:1:POT:U~PAD:1:PAD_U", denominator="MAIN:1:POT:I~PAD:1:PAD_I"),
]
OUTPUT_POTENTIOSTATS = [
zl.PotentiostatConfiguration(uri="MAIN:1:POT"),
]
POTENTIOSTAT_RANGES = {
"MAIN:1:POT": {"voltage_range_index": 0, "compliance_range_index": 0},
}
hw_settings_job = zl.control.SetHardwareSettingsJob(
channels=CHANNELS,
impedance_configurations=IMPEDANCE_CONFIGS,
output_potentiostats=OUTPUT_POTENTIOSTATS,
)
link.do_job(hw_settings_job)
# Hardware Initialization Taken Over by the Zahner Lab Finished
start_current = None
i = None
start_current = CustomExperimentHelpers.parse_engineering("8m")
switch_on_job = zl.control.SwitchOnJob(
potentiostat="MAIN:1:POT",
coupling=zl.PotentiostatCoupling.GALVANOSTATIC,
bias=start_current,
voltage_range_index=POTENTIOSTAT_RANGES.get("MAIN:1:POT", {}).get("voltage_range_index", 0),
compliance_range_index=POTENTIOSTAT_RANGES.get("MAIN:1:POT", {}).get("compliance_range_index", 0),
)
link.do_job(switch_on_job)
for i in np.logspace(np.log10(start_current), np.log10((CustomExperimentHelpers.parse_engineering("2"))), num=int((CustomExperimentHelpers.parse_engineering("10")))).tolist():
poga_job = zl.meas.PogaJob(
bias=i,
duration=(CustomExperimentHelpers.parse_engineering("10")),
output_data_rate=(CustomExperimentHelpers.parse_engineering("50")),
autorange=True,
current_range=(CustomExperimentHelpers.parse_engineering("200m")),
stop_conditions=CustomExperimentHelpers.active_stop_conditions(),
)
link.do_job(poga_job)
poga_data = link.get_job_result_data(poga_job)
CustomExperimentHelpers.save_dataset(('stabilization_at_' + str(i)), poga_data)
eis_job = zl.meas.EisGenerateJob(
bias=i,
min_frequency=(CustomExperimentHelpers.parse_engineering("10")),
max_frequency=(CustomExperimentHelpers.parse_engineering("500k")),
start_frequency=(CustomExperimentHelpers.parse_engineering("1k")),
points_per_decade_upper=int((CustomExperimentHelpers.parse_engineering("12"))),
points_per_decade_lower=int((CustomExperimentHelpers.parse_engineering("8"))),
pre_duration=(CustomExperimentHelpers.parse_engineering("50m")),
pre_waves=int((CustomExperimentHelpers.parse_engineering("1"))),
meas_duration=(CustomExperimentHelpers.parse_engineering("200m")),
meas_waves=int((CustomExperimentHelpers.parse_engineering("4"))),
amplitude=(min(max(i * CustomExperimentHelpers.parse_engineering("0.1"), CustomExperimentHelpers.parse_engineering("0")), CustomExperimentHelpers.parse_engineering("50m"))),
)
link.do_job(eis_job)
eis_data = link.get_job_result_data(eis_job)
CustomExperimentHelpers.save_dataset(('eis_at_' + str(i)), eis_data)
switch_off_job = zl.control.SwitchOffJob(
potentiostat="MAIN:1:POT",
)
link.do_job(switch_off_job)
finally:
link.disconnect()
if CustomExperimentHelpers.get_datasets():
exporter = zl.xml.ZXmlExporter()
for name, dataset in CustomExperimentHelpers.get_datasets().items():
exporter.save_as_file_standalone(dataset, name + ".zdx", name)
print(f"Saved dataset: {name}.zdx")
You can paste this generated script into your Python file and run it as-is. From here it is easy to extend - for example by adjusting the list of currents, changing the frequency range, or adding your own evaluation of the results. Please note that it is not possible to convert a Python script back into a Custom Experiment Builder script.
As an example, the following code creates a contour plot with Matplotlib to visualize how the impedance spectra change with DC current. It is added at the very end of the generated script, after the connection to the instrument has been closed and the datasets have been saved to disk.
# From here on the code is not generated and was added manually.
#
# Plot all measured spectra together as an impedance contour plot.
# The X-axis corresponds to the frequency, the Y-axis is the DC current and the
# impedance is represented by the color.
import glob
import matplotlib.pyplot as plt
from matplotlib import colors
from matplotlib.ticker import EngFormatter
# Load the saved EIS spectra back from disk. Each spectrum was measured at a
# different DC current, which is encoded in the file name (e.g. "eis_at_0.01.zdx").
importer = zl.xml.ZXmlImporter()
currents: list[float] = []
absolute_impedances: list[np.ndarray] = []
frequencies: np.ndarray = np.array([])
for file_name in glob.glob("eis_at_*.zdx"):
current = float(file_name[len("eis_at_") : -len(".zdx")])
dataset = importer.import_from_file_as_eis_dataset(file_name)
spectrum_frequencies = np.array(dataset.get_frequencies())
impedances = dataset.get_impedance_data().get_calculated_complex_impedance_track()
# The EIS sweep starts at the start frequency, runs up to the maximum and
# then down to the minimum, so the range around the start frequency is
# measured twice. Keep only the unique frequencies, ordered from highest to
# lowest, to remove this overlap.
frequencies, unique_indices = np.unique(spectrum_frequencies, return_index=True)
frequencies = frequencies[::-1]
unique_indices = unique_indices[::-1]
currents.append(current)
absolute_impedances.append(np.abs(np.array(impedances))[unique_indices])
if absolute_impedances:
# Sort the spectra by DC current so the contour is drawn in order.
order = np.argsort(currents)
currents = np.array(currents)[order]
absolute_impedances = np.array(absolute_impedances)[order]
# For the logarithmic color scaling of the impedance, the ticks and levels
# have to be prepared manually, since this cannot be done automatically.
X, Y = np.meshgrid(frequencies, currents)
impedance_figure, impedance_plot = plt.subplots(1, 1)
impedance_figure.suptitle("Impedance vs. DC Current vs. Frequency")
ticks = np.power(
10,
np.arange(
np.floor(np.log10(absolute_impedances.min()) - 1),
np.ceil(np.log10(absolute_impedances.max()) + 1),
),
)
levels = np.logspace(
np.floor(np.log10(absolute_impedances.min()) - 1),
np.ceil(np.log10(absolute_impedances.max())),
num=200,
)
impedance_contour = impedance_plot.contourf(
X,
Y,
absolute_impedances,
levels=levels,
norm=colors.LogNorm(
absolute_impedances.min(), absolute_impedances.max(), True
),
cmap="jet",
)
impedance_plot.set_xlabel("Frequency")
impedance_plot.set_xscale("log")
impedance_plot.xaxis.set_major_formatter(EngFormatter(unit="Hz"))
impedance_plot.set_ylabel("DC Current")
impedance_plot.set_yscale("log")
impedance_plot.yaxis.set_major_formatter(EngFormatter(unit="A"))
impedance_bar = impedance_figure.colorbar(
impedance_contour, ticks=ticks, format=EngFormatter(unit="$\\Omega$")
)
impedance_bar.set_label("| Impedance |")
impedance_figure.set_size_inches(14, 12)
plt.tight_layout()
plt.show()
impedance_figure.savefig("impedance_contour.svg")
The image impedance_contour.svg generated by this code looks as follows:
This short example shows the idea behind the whole workflow:
It starts from generated code that provides a correct, working foundation.
A few additional lines extend it - here, loading the saved spectra and plotting them.
Together, this turns a series of individual measurements into a single, clear, combined result - here visualized as an impedance contour plot.