Polarization Measurements and Data Handling

This advanced example demonstrates how to perform multiple polarization measurements in sequence, process the resulting data, and access it easily from Python.

Basic steps like connecting to the device and calibration are assumed to be done already and are not covered here.

import sys
import os
import copy
import matplotlib.pyplot as plt
from matplotlib.ticker import EngFormatter
import zahner_link as zl

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()}")
connected successfully

Creating On and Off Jobs

Since these jobs are used frequently, we create them at the beginning. You can still change the job parameters after creation if needed.

switch_on_job = zl.control.SwitchOnJob(
    potentiostat="MAIN:1:POT",
    coupling=zl.PotentiostatCoupling.POTENTIOSTATIC,
    bias=1.0,
    voltage_range_index=0,
    compliance_range_index=0,
)
switch_off_job = zl.control.SwitchOffJob(potentiostat="MAIN:1:POT")

Error Handling and Switching On

The method do_job() of the class ZahnerLinkExc throws an exception if the job does not run successfully.

If the non exception class ZahnerLink is used, the returned ErrorObject of do_job() must be checked.

The was_successful() method returns True if the job ran successfully.
You can use get_last_job_error_message() to get the error message text, and get_last_job_status() returns an enum describing the status.

The get_last_job_info() method returns a JobInfo object. This object provides more detailed status information via methods like get_status() and get_status_detail().

The example below shows the error handling for SwitchOnJob. You cannot switch on a potentiostat that’s already on. However, switching off an already-off potentiostat is allowed and safe.

The ErrorHandling Example goes into more detail about possible errors and how to handle them in order to keep the examples clear.

try:
    print("first switch on")
    link.do_job(switch_on_job)

    print("second switch on")
    link.do_job(switch_on_job)

except zl.ZahnerLinkException as e:
    print(f"\ncaught exception: {e}")
    error_object: zl.ErrorObject = e.error
    print(f"ErrorObject: {error_object}")
    print(f"ErrorObject==True: {error_object==True}")
    print(f"ErrorObject code enum: {error_object.get_error_code_enum()}")
    print(f"ErrorObject message: {error_object.get_message_formatted()}")

    print(f"Job was successfull: {switch_on_job.was_successful()}")
    print(f"Job status: {switch_on_job.get_last_job_status()}")
    print(f"Job error message: {switch_on_job.get_last_job_error_message()}")
    info = switch_on_job.get_last_job_info()
    print(f"JobInfo status: {info.get_status()}")
    print(f"JobInfo status detail: {info.get_status_detail()}")
    print(f"JobInfo error message: {info.get_error_message()}")
first switch on
second switch on

caught exception: an unexpected exception occured: SystemException: type: 8; message: Potentiostat '42176:POT' must be switched off, before switched on.
ErrorObject: an unexpected exception occured: SystemException: type: 8; message: Potentiostat '42176:POT' must be switched off, before switched on.
ErrorObject==True: True
ErrorObject code enum: ErrorCodeEnum.UNEXPECTED_EXCEPTION
ErrorObject message: an unexpected exception occured: SystemException: type: 8; message: Potentiostat '42176:POT' must be switched off, before switched on.
Job was successfull: False
Job status: JobStatusEnum.FAILED
Job error message: an unexpected exception occured: SystemException: type: 8; message: Potentiostat '42176:POT' must be switched off, before switched on.
JobInfo status: JobStatusEnum.FAILED
JobInfo status detail: JobStatusDetailEnum.RUNTIME_ERROR
JobInfo error message: an unexpected exception occured: SystemException: type: 8; message: Potentiostat '42176:POT' must be switched off, before switched on.

Potentiostatic Polarization

The potentiostat is already switched on in potentiostatic mode.

Now we create a polarization job of type PogaJob for potentiostatic operation. After running the job, the measurement data from the last run is stored in the job object.

Important: If you run a job more than once, the measurement data is overwritten. Always retrieve the data after each measurement if you want to keep it.

potentiostatic_polarization_job = zl.meas.PogaJob(
    bias=1,  # 1 V
    duration=11.0,
    output_data_rate=25,
    autorange=True,
    current_range=0.1,
)
link.do_job(potentiostatic_polarization_job)
<zahner_link._zahner_link.ErrorObject at 0x1d5233e3a30>

Switching to Galvanostatic Mode

After potentiostatic polarization, we switch the potentiostat off and then back on in galvanostatic mode.

The code below shows how to access and change the job parameters to set galvanostatic mode and a bias of -1 mA for the switch-on process.

Each job has a parameters attribute you can use to adjust its settings.

link.do_job(switch_off_job)
switch_on_job.parameters.coupling = zl.PotentiostatCoupling.GALVANOSTATIC
switch_on_job.parameters.bias = -0.001  # -1 mA
link.do_job(switch_on_job)
<zahner_link._zahner_link.ErrorObject at 0x1d5233e0230>

Galvanostatic Polarization

After switching to galvanostatic mode, we perform a galvanostatic polarization with -5 mA.

As an alternative to do_job(), jobs that generate measurement data can also be executed with do_measurement(). The return value is then the data object.

galvanostatic_polarization_job = zl.meas.PogaJob(
    bias=-0.005,  # -5 mA
    duration=5.0,
    output_data_rate=25,
    autorange=True,
    current_range=0.1,
)
galvanostatic_data = link.do_measurement(galvanostatic_polarization_job)

Accessing Data in Python

The measurement data from the last few jobs can be retrieved with get_job_result_data(). This can be done as often as desired, even if jobs were executed with do_measurement().

The get_job_result_data() method returns a DcDataset object or an EisDataset. This object has an append() method that lets you combine data tracks and automatically continues the time axis.

The time for switching between potentiostatic and galvanostatic is ignored; the time axis just continues. There’s also a short dead time between jobs while Python and the device process the transition.

You can pass an optional time_offset parameter to append if you want to shift the time axis for the added dataset.

potentiostatic_data = link.get_job_result_data(potentiostatic_polarization_job)
galvanostatic_data_second_copy = link.get_job_result_data(
    galvanostatic_polarization_job
)

complete_data = copy.deepcopy(potentiostatic_data)
complete_data.append(galvanostatic_data, time_offset=0)
True

Switching Off and Disconnecting

When you’re done with the IM7, you can switch it off and disconnect.

The job objects remain available and can still be used after disconnecting.

link.do_job(switch_off_job)
link.disconnect()

Exporting Data in Zahner Format

You can export jobs and their parameters in Zahner format. It’s also possible to save multiple jobs in a single file, as shown below.

The append_dataset() method of Measurement is used for this.

However, it would also be possible to save complete_data in the Measurement.

xml_measurement = zl.xml.Measurement(potentiostatic_data)
xml_measurement.append_dataset(galvanostatic_data)

exporter = zl.xml.ZXmlExporter()
exporter.set_compact_xml(False)
exporter.save_as_file_standalone(xml_measurement, "polarization.zmx")
0

Exporting Data from Zahner Format

The following lines of code show how to reload the data saved in the previous step using the method import_from_file_as_measurement() of the ZXmlImporter class.

Then, to show that they are equal, the length of the imported dataset is compared to that of the original dataset.

importer = zl.xml.ZXmlImporter()
imported_measurement = importer.import_from_file_as_measurement("polarization.zmx")
print(f"imported measurement has {len(imported_measurement.get_datasets())} datasets")

potentiostatic_data_imported = imported_measurement.get_datasets()[0]
galvanostatic_data_imported = imported_measurement.get_datasets()[1]
complete_data_imported = copy.deepcopy(potentiostatic_data_imported)
complete_data_imported.append(galvanostatic_data_imported, time_offset=0)

print(f"complete data length: {len(complete_data.get_dc_track('time'))} points")
print(
    f"imported complete data length: {len(complete_data_imported.get_dc_track('time'))} points"
)
imported measurement has 2 datasets
complete data length: 400 points
imported complete data length: 400 points

Available Tracks in the DcDataset

You can access the header of a DcDataset using the get_header() method. This provides information about the data type. Note that the type may change when you append data, since any data can be merged.

Use get_columns() to get the available columns as ColumnHeader objects. These provide details about each track, though not all fields are always present.

header = complete_data_imported.get_header()
print(f"measurement type: {header.get_type()}")
print(f"measurement short type: {header.get_short_type()}")
print("tracks:")
for column in header.get_columns():
    print(
        f"\t{column.get_dimension()} in '{column.get_unit()}' of channel urn '{column.get_urn()}'"
    )
measurement type: Polarization
measurement short type: poga
tracks:
	time in 's' of channel urn 'time'
	voltage in 'V' of channel urn '42176:POT:U~43128:PAD_U'
	current in 'A' of channel urn '42176:POT:I~43128:PAD_I'
	shunt in 'index' of channel urn 'shunt'

Accessing Data Tracks

Use get_dc_track() to extract a data track as an array by its dimension name (e.g., “time”, “voltage”, or “current”).

time = complete_data_imported.get_dc_track("time")
voltage = complete_data_imported.get_dc_track("voltage")
current = complete_data_imported.get_dc_track("current")

Plotting Data with Matplotlib

We use matplotlib to plot the data. For details on plotting, see the matplotlib documentation.

In the plot below, you can see that current, voltage, and time from both jobs are combined into single arrays and plotted together.

fig, ax1 = plt.subplots()
ax2 = ax1.twinx()
(line1,) = ax1.plot(time, voltage, color="blue", label="Voltage")
(line2,) = ax2.plot(time, current, color="red", label="Current")
ax1.legend(handles=[line1, line2])

ax1.xaxis.set_major_formatter(EngFormatter(unit="$s$"))
ax1.yaxis.set_major_formatter(EngFormatter(unit="$V$"))
ax1.set_xlabel("Time")
ax1.set_ylabel("Voltage")
ax1.grid(which="both")
ax2.xaxis.set_major_formatter(EngFormatter(unit="$s$"))
ax2.yaxis.set_major_formatter(EngFormatter(unit="$A$"))
ax2.set_xlabel("Time")
ax2.set_ylabel("Current")
ax2.grid(which="both")

fig.set_size_inches(18, 10)
plt.show()
fig.savefig("polarization.svg")
../../../../_images/ada98dc4795c8a72b44c20de39fdc6de604b339f961dbd2d10a7aab7471d531b.png