# -*- coding: utf-8 -*-
#
# Copyright © keithley2600 Project Contributors
# Licensed under the terms of the MIT License
# (see keithley2600/__init__.py for details)
"""
Submodule defining classes to store, plot, and save measurement results.
"""
import os
import re
import numpy as np
def find_numbers(string):
fmt = r'[-+]?[.]?[\d]+(?:,\d\d\d)*[\.]?\d*(?:[eE][-+]?\d+)?'
string_list = re.findall(fmt, string)
float_list = [float(s) for s in string_list]
return float_list
[docs]class ColumnTitle(object):
"""
Class to hold a column title.
:param str name: Column name.
:param str unit: Column unit.
:param str unit_fmt: Formatting directive for units when generating string
representations. By default, units are enclosed in square brackets (e.g.,
"Gate voltage [V]").
"""
def __init__(self, name, unit=None, unit_fmt='[{}]'):
self.name = name
self.unit = '' if unit is None else unit
self.unit_fmt = unit_fmt
def has_unit(self):
return self.unit != ''
def set_unit(self, unit):
self.unit = unit
def __repr__(self):
return "<{0}(title='{1}', unit='{2}')>".format(
self.__class__.__name__, self.name, self.unit)
def __str__(self):
if self.has_unit():
return self.name + ' ' + self.unit_fmt.format(self.unit)
else:
return self.name
# noinspection PyTypeChecker
[docs]class ResultTable(object):
"""
Class that holds measurement data. All data is stored internally as a numpy array with
the first index designating rows and the second index designating columns.
Columns must have titles and can have units. It is possible to access the data in a
column by its title in a dictionary type notation.
:param list column_titles: List of column titles.
:param list units: List of column units.
:param data: Numpy array holding the data with the first index designating rows and
the second index designating columns. If ``data`` is ``None``, an empty array with
the required number of columns is created.
:type data: numpy.ndarray or NoneType
:param dict params: Dictionary of measurement parameters.
:Examples:
Create a :class:`ResultTable` to hold current-vs-time data:
>>> import time
>>> import numpy as np
>>> from keithley2600 import ResultTable
>>> # create dictionary of relevant measurement parameters
>>> pars = {'recorded': time.asctime(), 'sweep_type': 'iv'}
>>> # create ResultTable with two columns
>>> rt = ResultTable(['Voltage', 'Current'], ['V', 'A'], pars)
>>> # create a live plot of the data
>>> fig = rt.plot(live=True)
Create a :class:`Keithley2600` instance and record some data:
>>> from keithley2600 import Keithley2600
>>> k = Keithley2600('TCPIP0::192.168.2.121::INSTR')
>>> for v in range(11): # measure IV characteristics from 0 to 10 V
... k.applyVoltage(k.smua, 10)
... i = k.smua.measure.i()
... rt.append_row([v, i])
... time.sleep(1)
Print a preview of data to the console:
>>> print(rt)
Voltage [V] Current [A]
0.0000e+00 1.0232e-04
1.0000e+00 2.2147e-04
2.0000e+00 3.6077e-04
3.0000e+00 5.2074e-04
4.0000e+00 6.9927e-04
Save the recorded data to a tab-delimited text file:
>>> rt.save('~/Desktop/stress_test.txt')
"""
COMMENT = '# '
DELIMITER = '\t'
PARAM_DELIMITER = ': '
LINE_BREAK = '\n'
UNIT_FORMAT = '[{}]'
def __init__(self, column_titles=None, units=None, data=None, params=None):
if column_titles is None:
column_titles = []
ncols = len(column_titles)
if units is None:
units = [''] * ncols
self.titles = [ColumnTitle(n, u, self.UNIT_FORMAT)
for n, u in zip(column_titles, units)]
if data is None:
self.data = np.array([[]]*ncols).transpose()
else:
self.data = np.array(data)
if params is None:
self.params = {}
else:
self.params = params
@property
def nrows(self):
"""Number of rows of the ResultTable."""
return self.data.shape[0]
@property
def ncols(self):
"""Number of columns of the ResultTable."""
return len(self.titles)
@property
def shape(self):
"""A tuple representing the dimensionality of the ResultTable."""
return self.data.shape[0], len(self.titles)
@property
def column_names(self):
"""List of strings with column names."""
return [title.name for title in self.titles]
@column_names.setter
def column_names(self, names_list):
"""Setter: List of strings with column names."""
if not all(isinstance(x, str) for x in names_list):
raise ValueError("All column names must be of type 'str'.")
elif not len(names_list) == self.ncols:
raise ValueError('Number of column names does not match number of columns.')
for title, name in zip(self.titles, names_list):
title.name = name
@property
def column_units(self):
"""List of strings with column units."""
return [title.unit for title in self.titles]
@column_units.setter
def column_units(self, units_list):
"""Setter: List of strings with column units."""
if not all(isinstance(x, str) for x in units_list):
raise ValueError("All column_units must be of type 'str'.")
elif not len(units_list) == self.ncols:
raise ValueError('Number of column_units does not match number of columns.')
for title, unit in zip(self.titles, units_list):
title.unit = unit
[docs] def has_unit(self, col):
"""
Returns ``True`` column units have been set and ``False`` otherwise.
:param col: Column index or name.
:type col: int or str
:returns: ``True`` if column_units have been set, ``False`` otherwise.
:rtype: bool
"""
if not isinstance(col, int):
col = self.column_names.index(col)
return self.titles[col].unit != ''
[docs] def get_unit(self, col):
"""
Get unit of column ``col``.
:param col: Column index or name.
:type col: int or str
:returns: Unit string.
:rtype: str
"""
if not isinstance(col, int):
col = self.column_names.index(col)
return self.titles[col].unit
[docs] def set_unit(self, col, unit):
"""
Set unit of column ``col``.
:param col: Column index or name.
:type col: int or str
:param str unit: Unit string.
"""
if not isinstance(col, int):
col = self.column_names.index(col)
self.titles[col].unit = unit
[docs] def clear_data(self):
"""
Clears all data.
"""
self.data = np.array([[]]*self.ncols).transpose()
[docs] def append_row(self, data):
"""
Appends a single row to the data array.
:param data: Iterable with the same number of elements as columns in the data
array.
"""
if not len(data) == self.ncols:
raise ValueError('Length must match number of columns: %s' % self.ncols)
self.data = np.append(self.data, [data], 0)
[docs] def append_rows(self, data):
"""
Appends multiple rows to the data array.
:param data: List of lists or numpy array with dimensions matching the data array.
"""
self.data = np.append(self.data, data, 0)
[docs] def append_column(self, data, name, unit=None):
"""
Appends a single column to the data array.
:param data: Iterable with the same number of elements as rows in the data array.
:param str name: Name of new column.
:param str unit: Unit of values in new column.
"""
if self.data.size == 0:
self.data = np.transpose([data])
else:
self.data = np.append(self.data, np.transpose([data]), 1)
self.titles.append(ColumnTitle(name, unit, self.UNIT_FORMAT))
[docs] def append_columns(self, data, column_titles, units=None):
"""
Appends multiple columns to data array.
:param list data: List of columns to append.
:param list column_titles: List of column titles (strings).
:param list units: List of units for new columns (strings).
"""
if self.data.size == 0:
self.data = np.transpose(data)
else:
self.data = np.append(self.data, np.transpose(data), 1)
for name, unit in zip(column_titles, units):
self.titles.append(ColumnTitle(name, unit, self.UNIT_FORMAT))
[docs] def get_row(self, i):
"""
:returns: Numpy array with data from row ``i``.
:rtype: :class:`numpy.ndarray`
"""
return self.data[i, :]
[docs] def get_column(self, i):
"""
:returns: Numpy array with data from column ``i``.
:rtype: :class:`numpy.ndarray`
"""
return self.data[:, i]
def _column_title_string(self):
"""
Creates column title string.
:returns: String with column titles.
:rtype: str
"""
column_titles = [str(title) for title in self.titles]
return self.DELIMITER.join(column_titles)
def _parse_column_title_string(self, title_string):
"""
Parses a column title string.
:param str title_string: String to parse.
:returns: List of :class:`ColumnTitle` instances.
"""
title_string = title_string.lstrip(self.COMMENT)
# use only alphabetic characters in `unique`
# otherwise `re.escape` may inadvertently escape them in Python < 3.7
unique = 'UNIQUESTRING'
assert unique not in self.UNIT_FORMAT
regexp_name = '(?P<name>.*) '
regexp_unit = re.escape(self.UNIT_FORMAT.format(unique)).replace(unique,
'(?P<unit>.*)')
strings = title_string.split(self.DELIMITER)
titles = []
for s in strings:
m = re.search(regexp_name + regexp_unit, s)
if m is None:
titles.append(ColumnTitle(s, unit_fmt=self.UNIT_FORMAT))
else:
titles.append(ColumnTitle(m.group('name'), m.group('unit'),
self.UNIT_FORMAT))
return titles
def _param_string(self):
"""
Creates string containing all parameters from :attr:`params` as key, value pairs
in separate lines marked as comments.
:returns: Parameter string.
:rtype: str
"""
lines = []
for key, value in self.params.items():
lines.append(str(key) + self.PARAM_DELIMITER + str(value))
return self.LINE_BREAK.join(lines)
def _parse_param_string(self, header):
"""
Parses comment section of _header to extract measurement parameters
:returns: Dictionary containing measurement parameters.
:rtype: dict
"""
params = {}
lines = header.split(self.LINE_BREAK)
for line in lines:
if (line.startswith(self.COMMENT) and self.PARAM_DELIMITER in line
and self.DELIMITER not in line):
contents = line.lstrip(self.COMMENT)
key, value = contents.split(self.PARAM_DELIMITER)
try:
params[key] = float(value)
except ValueError:
if value in ['True', 'true']:
params[key] = True
elif value in ['False', 'false']:
params[key] = False
else:
params[key] = value
return params
def _header(self):
"""
Outputs a full _header string with measurement parameters and column titles
(including units).
:returns: Header as string.
:rtype: str
"""
params_string = self._param_string()
titles_string = self._column_title_string()
return self.LINE_BREAK.join([params_string, titles_string])
def _parse_header(self, header):
"""
Parses a _header string . Returns list of :class:`ColumnTitle` objects and
measurement parameters in dictionary.
:param str header: Header to parse.
:returns: Tuple with titles and params.
:rtype: tuple(str, str)
"""
header = header.strip(self.LINE_BREAK)
last_line = header.split(self.LINE_BREAK)[-1]
titles = self._parse_column_title_string(last_line)
params = self._parse_param_string(header)
return titles, params
[docs] def save(self, filename, ext='.txt'):
"""
Saves the result table to a text file. The file format is:
- The _header contains all measurement parameters as comments.
- Column titles contain column_names and column_units of measured quantity.
- Delimited columns contain the data.
Files are saved with the specified extension (default: '.txt'). The classes
default delimiters are used to separate columns and rows.
:param str filename: Path of file to save. Relative paths are interpreted with
respect to the current working directory.
:param str ext: File extension. Defaults to '.txt'.
"""
base_name = os.path.splitext(filename)[0]
filename = base_name + ext
np.savetxt(filename, self.data, delimiter=self.DELIMITER, newline=self.LINE_BREAK,
header=self._header(), comments=self.COMMENT)
[docs] def save_csv(self, filename):
"""
Saves the result table to a csv file. The file format is:
- The _header contains all measurement parameters as comments.
- Column titles contain column_names and column_units of measured quantity.
- Comma delimited columns contain the data.
Files are saved with the extension '.csv' and other extensions are
overwritten.
:param str filename: Path of file to save. Relative paths are interpreted with
respect to the current working directory.
"""
old_delim = self.DELIMITER
old_line_break = self.LINE_BREAK
self.DELIMITER = ','
self.LINE_BREAK = '\n'
self.save(filename, ext='.csv')
self.DELIMITER = old_delim
self.LINE_BREAK = old_line_break
[docs] def load(self, filename):
"""
Loads data from csv or tab delimited tex file. The _header is searched for
measurement parameters.
:param str filename: Absolute or relative path of file to load.
"""
old_delim = self.DELIMITER
old_line_break = self.LINE_BREAK
base_name, ext = os.path.splitext(filename)
if ext == '.csv':
self.DELIMITER = ','
self.LINE_BREAK = '\n'
# read info string and header
with open(filename) as f:
lines = f.readlines()
header_length = sum([l.startswith(self.COMMENT) for l in lines])
header = ''.join(lines[:header_length])
self.titles, self.params = self._parse_header(header)
# read data as 2D numpy array
self.data = np.loadtxt(filename)
self.DELIMITER = old_delim
self.LINE_BREAK = old_line_break
[docs] def plot(self, x_clmn=0, y_clmns=None, func=lambda x: x, live=False, **kwargs):
"""
Plots the data. This method should not be called from a thread. The column
containing the x-axis data is specified (defaults to first column), all other data
is plotted on the y-axis. This method requires Matplotlib to be installed and
accepts, in addition to the arguments documented here, the same keyword arguments
as :func:`matplotlib.pyplot.plot`.
Column titles are taken as legend labels. :func:`plot` tries to determine a common
y-axis unit and name from all given labels.
:param x_clmn: Integer or name of column containing the x-axis data.
:type x_clmn: int or str
:param list y_clmns: List of column numbers or column names for y-axis data. If
not given, all columns will be plotted against the x-axis column.
:param function func: Function to apply to y-data before plotting.
:param bool live: If ``True``, update the plot when new data is added (default:
``False``). Plotting will be carried out in the main (GUI) thread, therefore
take care not to block the thread. This can be achieved for instance by adding
data in a background thread which carries out the measurement, or by calling
`matplotlib.pyplot.pause` after adding data to give the GUI time to update.
:returns: :class:`ResultTablePlot` instance with Matplotlib figure.
:rtype: :class:`ResultTablePlot`
:raises ImportError: If import of matplotlib fails.
"""
try:
import matplotlib
import matplotlib.pyplot as plt
except ImportError:
raise ImportError('Matplotlib is required for plotting.')
if live and not matplotlib.get_backend() == 'Qt5Agg':
print("'Qt5Agg' backend to Matplotlib is required for live plotting.")
live = False
plot = ResultTablePlot(self, x_clmn, y_clmns, func, live=live, **kwargs)
return plot
def __repr__(self):
titles = [str(t) for t in self.titles]
return '<{0}(columns={1}, data=array(...))>'.format(
self.__class__.__name__, str(titles))
def __str__(self):
# print first 7 rows of ResultTable to console
n = min(7, self.nrows)
spacer = 3*' '
title_strings = [str(t) for t in self.titles]
max_lengths = [max(len(ts), 11) for ts in title_strings]
row_strings = [spacer.join([t.rjust(m) for t, m in zip(title_strings, max_lengths)])]
for row in self.data[0:n, :]:
strings = ['{:.4e}'.format(x) for x in row]
strings = [s.rjust(m) for s, m in zip(strings, max_lengths)]
row_strings.append(spacer.join(strings))
return '\n'.join(row_strings)
# =============================================================================
# Dictionary compatibility functions. This allows access to all columns of the
# table with their names as keys.
# =============================================================================
def keys(self):
return self.column_names
def has_key(self, key):
return self.__contains__(key)
def values(self):
return [self.get_column(i) for i in range(self.ncols)]
def items(self):
return zip(self.keys(), self.values())
def __getitem__(self, key):
"""
Gets values in column with name ``key``.
:param str key: Column name.
:returns: Column content as numpy array.
:rtype: :class:`numpy.ndarray`
"""
if key not in self.column_names:
raise KeyError("No such column '{0}'.".format(key))
if not isinstance(key, str):
raise TypeError("Key must be of type 'str'.")
return self.get_column(self.column_names.index(key))
def __setitem__(self, key, value):
"""
Sets values in column with name ``key``.
:param str key: Column name.
:param value: Iterable containing column values.
"""
if not isinstance(key, str):
raise TypeError("Key must be of type 'str'.")
if key not in self.column_names:
self.append_column(value, name=key)
else:
self.data[:, self.column_names.index(key)] = np.transpose(value)
def __delitem__(self, key):
"""
Deletes column with name ``key``.
:param str key:
"""
i = self.column_names.index(key)
self.data = np.delete(self.data, i, axis=1)
self.titles.pop(i)
def __iter__(self):
self._n = 0
return self
def __next__(self):
if self._n + 1 <= self.ncols:
r = self.column_names[self._n]
self._n += 1
return r
else:
raise StopIteration
def __contains__(self, key):
return key in self.column_names
def __len__(self):
return self.ncols
[docs]class FETResultTable(ResultTable):
"""
Class to handle, store and load transfer and output characteristic data of FETs.
:class:`TransistorSweepData` inherits from :class:`ResultTable` and overrides the
plot method.
"""
@property
def sweep_type(self):
if 'sweep_type' in self.params.keys():
return self.params['sweep_type']
else:
return ''
@sweep_type.setter
def sweep_type(self, sweep_type):
self.params['sweep_type'] = sweep_type
[docs] def plot(self, *args, **kwargs):
"""
Plots the transfer or output curves. Overrides :func:`ResultTable.plot`.
Absolute values are plotted, on a linear scale for output characteristics
and a logarithmic scale for transfer characteristics. Takes the same arguments
as :func:`ResultTable.plot`.
:returns: :class:`ResultTablePlot` instance with Matplotlib figure.
:rtype: :class:`ResultTablePlot`
:raises ImportError: If import of matplotlib fails.
"""
plot = ResultTable.plot(self, func=np.abs, *args, **kwargs)
plot.ax.set_ylabel('I [A]')
if self.sweep_type == 'transfer':
plot.ax.set_yscale('log')
else:
plot.ax.set_yscale('linear')
return plot
[docs]class ResultTablePlot(object):
"""
Plots the data from a given :class:`ResultTable` instance. Axes labels are
automatically generated from column titles and units. This class requires Matplotlib
to be installed. In addition to the arguments documented here, class:`ResultTable`
accepts the same keyword arguments as :func:`matplotlib.pyplot.plot`.
:param result_table: :class:`ResultTable` instance with data to plot.
:type result_table: :class:`ResultTable`
:param x_clmn: Integer or name of column containing the x-axis data.
:type x_clmn: int or str
:param y_clmns: List of column numbers or column names for y-axis data. If not given,
all columns will be plotted against the x-axis column.
:type y_clmns: list(int or str)
:param function func: Function to apply to y-data before plotting.
:param bool live: If ``True``, update the plot when new data is added (default:
``False``). Plotting will be carried out in the main (GUI) thread, therefore
take care not to block the thread. This can be achieved for instance by adding
data in a background thread which carries out the measurement, or by calling
`matplotlib.pyplot.pause` after adding data to give the GUI time to update.
"""
def __init__(self, result_table, x_clmn=0, y_clmns=None, func=lambda x: x,
live=False, **kwargs):
try:
import matplotlib
import matplotlib.pyplot as plt
except ImportError:
raise ImportError('Matplotlib is required for plotting.')
if live and not matplotlib.get_backend() == 'Qt5Agg':
print("'Qt5Agg' backend to Matplotlib is required for live plotting.")
live = False
# input processing
self.result_table = result_table
if self.result_table.ncols < 2:
raise ValueError("'ResultTable' must at least contain two columns of data.")
self.x_clmn = self._to_column_number(x_clmn)
if y_clmns is None:
self.y_clmns = list(range(0, self.result_table.ncols))
self.y_clmns.remove(x_clmn)
else:
self.y_clmns = [self._to_column_number(c) for c in y_clmns]
self.func = func
# create plot
self.fig = plt.figure()
self.ax = self.fig.add_subplot(111)
line_labels = []
line_units = []
self.lines = []
x = self.result_table.data[:, self.x_clmn]
for c in self.y_clmns:
y = self.result_table.data[:, c]
y = self.func(y)
line = self.ax.plot(x, y, label=self.result_table.column_names[c], **kwargs)
self.lines.append(line[0])
line_labels.append(self.result_table.column_names[c])
line_units.append(self.result_table.column_units[c])
self.ax.set_xlabel(str(self.result_table.titles[x_clmn]))
y_label = os.path.commonprefix(line_labels)
y_unit = os.path.commonprefix(line_units)
if y_unit == '':
label_text = '%s' % y_label
else:
label_text = y_label + ' ' + self.result_table.UNIT_FORMAT.format(y_unit)
self.ax.set_ylabel(label_text)
self.ax.autoscale(enable=True, axis='x', tight=True)
self.fig.tight_layout()
self.fig.show()
if live and matplotlib.get_backend() == 'Qt5Agg':
self._timer = self.fig.canvas.new_timer()
self._timer.add_callback(self.update)
self._timer.start(100)
[docs] def show(self):
"""
Shows the plot.
"""
self.fig.show()
[docs] def update(self):
"""
Updates the plot with the data of the corresponding :class:`ResultTable`.
This will be called periodically when :param:``live`` is ``True``.
"""
x = self.result_table.data[:, self.x_clmn]
for line, column in zip(self.lines, self.y_clmns):
y = self.result_table.data[:, column]
y = self.func(y)
line.set_xdata(x)
line.set_ydata(y)
self.ax.relim()
self.ax.autoscale_view(True, True, True)
self.fig.canvas.draw()
def _to_column_number(self, c):
if not isinstance(c, int):
c = self.result_table.column_names.index(c)
return c