#!/usr/bin/env python
# -----------------------------------------------------------------------------
# :author: Pete R. Jemian
# :email: prjemian@gmail.com
# :copyright: (c) 2014-2020, Pete R. Jemian
#
# Distributed under the terms of the Creative Commons Attribution 4.0 International Public License.
#
# The full license is in the file LICENSE.txt, distributed with this software.
# -----------------------------------------------------------------------------
"""
Plot the data from scan N in a SPEC data file
.. autosummary::
~Selector
~ImageMaker
~LinePlotter
~MeshPlotter
~openSpecFile
Exceptions:
.. autosummary::
~NoDataToPlot
~NotPlottable
~ScanAborted
~UnexpectedObjectTypeError
"""
import os
import numpy
from . import charts
from . import spec # read SPEC data files
from . import singletons
from . import utils
[docs]class UnexpectedObjectTypeError(RuntimeError):
"""Incorrect Python object type: programmer error."""
[docs]class ScanAborted(RuntimeWarning):
"""Scan aborted before all points acquired."""
[docs]class NotPlottable(ValueError):
"""No plottable data for this scan."""
[docs]class NoDataToPlot(ValueError):
"""No data found."""
ABORTED_ATTRIBUTE_TEXT = "_aborted_"
[docs]class Selector(singletons.Singleton):
"""
associate SPEC scan macro names with image makers
:image maker: subclass of :class:`ImageMaker`
To include a custom image maker from outside this module,
create the subclass and then add it to an instance of this
class. Such as this plotter that defaults to a logarithmic
scale for the X axis for all `logxscan` macros:
from spec2nexus import specplot
class LogX_Plotter(specplot.ImageMaker):
def x_log(self):
return True
# ...
selector = specplot.Selector()
selector.add('logxscan', LogX_Plotter)
# ...
image_maker = specplot.Selector().auto(scan)
plotter = image_maker()
plotter.plot_scan(scan, fullPlotFile)
This class is a singleton which means you will always get the same
instance when you call this class many times in your program.
.. autosummary::
~auto
~add
~update
~get
~exists
~default
"""
default_key = "__default__"
def __init__(self):
self.db = {}
self.add(self.default_key, LinePlotter, default=True)
[docs] def auto(self, scan):
"""
automatically choose a scan image maker based on the SPEC scan macro
Selection Rules:
* macro ends with "scan": use :class:`LinePlotter`
* macro ends with "mesh": use :class:`MeshPlotter`
* default: use default image maker (initially :class:`LinePlotter`)
"""
if not isinstance(scan, (spec.SpecDataFileScan,)):
msg = "expected a SPEC scan object, received: "
msg += str(scan)
raise UnexpectedObjectTypeError(msg)
macro = scan.get_macro_name()
if self.exists(macro):
return self.get(macro)
# adapt for different scan macros
image_maker = self.default()
if macro == "hklscan":
image_maker = HKLScanPlotter
elif macro.lower().endswith("scan"):
image_maker = LinePlotter
elif macro.lower().endswith("mesh"):
image_maker = MeshPlotter
# register this macro name
self.add(macro, image_maker)
return image_maker
[docs] def add(self, key, value, default=False):
"""
register a new value by key
:param str key: name of key, typically the macro name
:raises KeyError: if key exists
:raises UnexpectedObjectTypeError: if value is not subclass of :class:`ImageMaker`
"""
if self.exists(key):
raise KeyError("key exists: " + key)
if not issubclass(value, ImageMaker):
msg = "expected subclass of ImageMaker, received type: "
msg += type(value).__name__
raise UnexpectedObjectTypeError(msg)
self.db[key] = value
if default:
self.db[self.default_key] = value
return value
[docs] def update(self, key, value, default=False):
"""
replace an existing key with a new value
:param str key: name of key, typically the macro name
:raises KeyError: if key does not exist
:raises UnexpectedObjectTypeError: if value is not subclass of :class:`ImageMaker`
"""
if not self.exists(key):
raise KeyError("key does not exist: " + key)
if not issubclass(value, ImageMaker):
msg = "expected subclass of ImageMaker, received type: "
msg += type(value).__name__
raise UnexpectedObjectTypeError(msg)
self.db[key] = value
if default:
self.db[self.default_key] = value
return value
[docs] def get(self, key):
"""
return a value by key
:returns: subclass of :class:`ImageMaker` or `None` if key not found
"""
return self.db.get(key)
[docs] def exists(self, key):
"""
is the key known?
"""
return key in self.db
[docs] def default(self):
"""
retrieve the value of the default key
"""
return self.get(self.default_key)
[docs]class ImageMaker(object):
"""
superclass to handle plotting of data from a SPEC scan
.. rubric:: Internal data model
:signal: name of the ``signal`` data (default data to be plotted)
:data: values of various collected arrays {label: array}
:axes: names of the axes of signal data
.. rubric:: USAGE:
#. Create a subclass of :class:`ImageMaker`
#. Override any of these methods:
.. autosummary::
~data_file_name
~make_image
~plottable
~plot_options
~retrieve_plot_data
.. rubric:: EXAMPLE
::
class LinePlotter(ImageMaker):
'''create a line plot'''
def make_image(self, plotFile):
'''
make MatPlotLib chart image from the SPEC scan
:param obj plotData: object returned from :meth:`retrieve_plot_data`
:param str plotFile: name of image file to write
'''
assert(self.signal in self.data)
assert(len(self.axes) == 1)
assert(self.axes[0] in self.data)
y = self.data[self.signal]
x = self.data[self.axes[0]]
xy_plot(x, y, plotFile,
title = self.plot_title(),
plot_subtitle = self.plot_subtitle(),
xtitle = self.x_title(),
ytitle = self.y_title(),
xlog = self.x_log(),
ylog = self.y_log(),
timestamp_str = self.timestamp())
sfile = specplot.openSpecFile(specFile)
scan = sfile.getScan(scan_number)
plotter = LinePlotter()
plotter.plot_scan(scan, plotFile, y_log=True)
"""
def __init__(self):
self.scan = None
self.settings = self._initialize_settings_()
self.signal = None
self.axes = []
self.data = {}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# support methods that a subclass might override
[docs] def data_file_name(self):
"""
the name of the file with the actual data
Usually, this is the SPEC data file
but it *could* be something else
"""
return self.scan.header.parent.fileName # self.scan.specFile
[docs] def make_image(self, plotFile):
"""
make MatPlotLib chart image from the SPEC scan
The data to be plotted are provided in:
* `self.signal`
* `self.axes`
* `self.data`
:param str plotFile: name of image file to write
"""
raise NotImplementedError(
"must implement make_image() in each subclass"
)
[docs] def plottable(self):
"""
can this data be plotted as expected?
"""
return False # override in subclass with specific tests
[docs] def plot_options(self):
"""
re-define any plot options in a subclass
"""
pass
[docs] def retrieve_plot_data(self):
"""
retrieve default plottable data from spec data file and store locally
This method must retrieve the data to be plotted, either from the
SPEC data file scan or from a file which name is provided
in the scan detalis.
These attributes must be set by this method:
:data: dictionary containing values of the various collected arrays {label: array}
:signal: name of the 'signal' data (default data to be plotted)
:axes: names of the axes of signal data
.. rubric:: Example data
::
self.data = {
'angle': [1, 2, 3, 4, 5],
'counts': [0. 2. 55. 3. 0]}
self.signal = 'counts'
self.axes = ['angle']
Raise any of these exceptions as appropriate:
.. autosummary::
~NoDataToPlot
~NotPlottable
~ScanAborted
"""
raise NotImplementedError(
"must implement retrieve_plot_data() in each subclass"
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# support methods that will not need to be defined in a subclass
[docs] def data_is_newer_than_plot(self, plotFile):
"""only proceed if mtime of SPEC data file is newer than plotFile"""
mtime_sdf = os.path.getmtime(self.data_file_name())
if os.path.exists(plotFile):
mtime_pf = os.path.getmtime(plotFile)
else:
mtime_pf = 0
return mtime_sdf > mtime_pf
[docs] def plot_scan(self, scan, plotFile, maker=None):
"""
make an image plot of the data in the scan
:param obj scan: instance of :class:`~spec2nexus.spec.SpecDataFileScan`
:param str plotFile: file name for plot output
"""
if not isinstance(scan, (spec.SpecDataFileScan,)):
raise UnexpectedObjectTypeError(
"scan object not a SpecDataFileScan"
)
if hasattr(scan, ABORTED_ATTRIBUTE_TEXT):
match_text = "Scan aborted after 0 points."
if scan.__getattribute__(ABORTED_ATTRIBUTE_TEXT) == match_text:
raise ScanAborted(match_text)
self.scan = scan
self.set_plot_title(self.plot_title() or self.data_file_name())
self.set_plot_subtitle(
self.plot_subtitle()
or "#" + str(self.scan.scanNum) + ": " + self.scan.scanCmd
)
self.set_timestamp(self.timestamp() or self.scan.date)
try:
self.retrieve_plot_data()
except KeyError as _exc:
if hasattr(self.scan, ABORTED_ATTRIBUTE_TEXT):
raise ScanAborted(
self.scan.__getattribute__(ABORTED_ATTRIBUTE_TEXT)
)
raise _exc
self.plot_options()
if self.plottable() and self.data_is_newer_than_plot(plotFile):
self.make_image(plotFile)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# support the self.settings dictionary with get & set methods
def _initialize_settings_(self):
"""
initial values are set to `None`
subclasses that set a value should first check if the value has already been set
unless explicitly replacing any customizations by the user
"""
return dict(
title=None,
subtitle=None,
x_title=None,
y_title=None,
x_log=None,
y_log=None,
z_log=None,
timestamp=None,
)
# TODO: apply property and setter decorations
[docs] def plot_title(self):
"""Return the plot title."""
return self.settings["title"]
[docs] def set_plot_title(self, text):
"""Set the plot title."""
self.settings["title"] = text
[docs] def plot_subtitle(self):
"""Return the plot_subtitle."""
return self.settings["subtitle"]
[docs] def set_plot_subtitle(self, text):
"""Set the plot_subtitle."""
self.settings["subtitle"] = text
[docs] def x_title(self):
"""Return the title for the X axis."""
return self.settings["x_title"]
[docs] def set_x_title(self, text):
"""Set the x axis title."""
self.settings["x_title"] = text
[docs] def y_title(self):
"""Return the title for the Y axis."""
return self.settings["y_title"]
[docs] def set_y_title(self, text):
"""Set the y axis title."""
self.settings["y_title"] = text
[docs] def x_log(self):
"""Boolean: should the X axis be plotted on a log scale?"""
return self.settings["x_log"]
[docs] def set_x_log(self, choice):
"""Set the x axis logarithmic if True."""
self.settings["x_log"] = choice
[docs] def y_log(self):
"""Boolean: should the Y axis be plotted on a log scale?"""
return self.settings["y_log"]
[docs] def set_y_log(self, choice):
"""Set the y axis logarithmic if True."""
self.settings["y_log"] = choice
[docs] def z_log(self):
"""Boolean: should the Z axis (image) be plotted on a log scale?"""
return self.settings["z_log"]
[docs] def set_z_log(self, choice):
"""Set the z axis (image) logarithmic if True."""
self.settings["z_log"] = choice
[docs] def timestamp(self):
"""Return the time of this scan as a string."""
return self.settings["timestamp"]
[docs] def set_timestamp(self, text):
"""Set the plot time stamp."""
self.settings["timestamp"] = text
[docs]class LinePlotter(ImageMaker):
"""
create a line plot
"""
[docs] def make_image(self, plotFile):
"""
make MatPlotLib chart image from the SPEC scan
:param str plotFile: name of image file to write
"""
assert self.signal in self.data
assert len(self.axes) == 1
assert self.axes[0] in self.data
y = self.data[self.signal]
x = self.data[self.axes[0]]
ts = self.timestamp()
charts.xy_plot(
x,
y,
plotFile,
title=self.plot_title(),
subtitle=self.plot_subtitle(),
xtitle=self.x_title(),
ytitle=self.y_title(),
xlog=self.x_log(),
ylog=self.y_log(),
timestamp_str=ts,
)
[docs] def plottable(self):
"""
can this data be plotted as expected?
"""
if self.signal in self.data:
signal = self.data[self.signal]
if (
signal is not None
and len(signal) > 0
and len(self.axes) == 1
):
if len(signal) == len(self.data[self.axes[0]]):
return True
return False
[docs] def plot_options(self):
"""
define the settings for this, accepting any non-default values first
"""
self.x_title() or self.set_x_title(self.axes[0])
self.y_title() or self.set_y_title(self.signal)
self.x_log() or self.set_x_log(False)
self.y_log() or self.set_y_log(False)
self.set_z_log(False)
[docs] def retrieve_plot_data(self):
"""retrieve default data from spec data file"""
# plot last column v. first column
assert isinstance(self.scan, spec.SpecDataFileScan)
self.signal = self.scan.column_last
if self.signal not in self.scan.data:
raise NoDataToPlot(str(self.scan))
self.axes = [
self.scan.column_first,
]
self.data = {
label: self.scan.data.get(label)
for label in self.scan.L
if label in self.scan.data
}
[docs]class HKLScanPlotter(LinePlotter):
"""
create a line plot from hklscan macros
"""
[docs] def retrieve_plot_data(self):
"""retrieve default data from spec data file"""
# standard hklscan macro handling
# find the real scan axis, the one that changes
for axis in "H K L".split():
data = self.scan.data.get(axis)
if data is None:
continue
# could compare start & end from scanCmd, this looks simpler
if min(data) != max(data):
# tell it to use this axis instead
self.axes = [
axis,
]
break
# if not found, default changes nothing
if data is None:
raise NotPlottable("no data in scan: " + str(self.scan))
if len(self.axes) == 0:
# issue #99: file: lmn40.spe, scan 244, hkl all fixed
axis = "data point number"
data = range(1, 1 + len(self.scan.data["H"]))
self.scan.data[axis] = data
self.set_x_title(axis + " (hkl all held constant)")
self.axes = [
axis,
]
self.scan.column_first = axis
self.data[axis] = data
self.signal = self.scan.column_last
self.data[self.signal] = self.scan.data[self.signal]
[docs]class MeshPlotter(ImageMaker):
"""
create a mesh plot (2-D image)
..rubric:: References:
:mesh 2-D parser: http://www.certif.com/spec_help/mesh.html
::
mesh motor1 start1 end1 intervals1 motor2 start2 end2 intervals2 time
:hklmesh 2-D parser: http://www.certif.com/spec_help/hklmesh.html
::
hklmesh Q1 start1 end1 intervals1 Q2 start2 end2 intervals2 time
"""
# see code in: writer.Writer.mesh() self._mesh_(scan)
[docs] def make_image(self, plotFile):
"""
make MatPlotLib chart image from the SPEC scan
:param str plotFile: name of image file to write
"""
if len(self.axes) == 2:
image = self.data[self.signal]
self.set_plot_subtitle(
"%s, %s" % (self.signal, self.scan.raw.splitlines()[0])
)
self.set_x_title(self.axes[0])
self.set_y_title(self.axes[1])
charts.make_png(
image,
plotFile,
[self.data[axis] for axis in self.axes],
title=self.plot_title(),
subtitle=self.plot_subtitle(),
timestamp_str=self.timestamp(),
xtitle=self.x_title(),
ytitle=self.y_title(),
log_image=self.z_log(),
)
elif len(self.axes) == 1:
# fallback to 1-D plot
y = self.data[self.signal]
x = self.data[self.axes[0]]
charts.xy_plot(
x,
y,
plotFile,
title=self.plot_title(),
subtitle=self.plot_subtitle(),
xtitle=self.x_title(),
ytitle=self.y_title(),
xlog=self.x_log(),
ylog=self.y_log(),
timestamp_str=self.timestamp(),
)
[docs] def plottable(self):
"""
can this data be plotted as expected?
"""
try:
assert self.signal in self.data
signal = numpy.array(self.data[self.signal])
assert len(self.axes) in (0, len(signal.shape))
for order, axis in enumerate(reversed(self.axes)):
assert axis in self.data
assert signal.shape[order] == len(self.data[axis])
except Exception:
return False
return True
[docs] def plot_options(self):
"""
define the settings for this, accepting any non-default values first
"""
if len(self.axes) == 1:
self.x_title() or self.set_x_title(self.axes[0])
self.y_title() or self.set_y_title(self.signal)
elif len(self.axes) == 2:
self.x_title() or self.set_x_title(self.axes[1])
self.y_title() or self.set_y_title(self.axes[0])
self.x_log() or self.set_x_log(False)
self.y_log() or self.set_y_log(False)
self.z_log() or self.set_z_log(False)
[docs] def retrieve_plot_data(self):
"""retrieve default data from spec data file
data parser for 2-D mesh and hklmesh
"""
(
label1,
_start1,
_end1,
intervals1,
label2,
_start2,
_end2,
intervals2,
_time,
) = self.scan.scanCmd.split()[1:]
if label1 not in self.scan.data:
label1 = self.scan.L[0] # mnemonic v. name
if label2 not in self.scan.data:
label2 = self.scan.L[1] # mnemonic v. name
axis1 = self.scan.data.get(label1)
axis2 = self.scan.data.get(label2)
intervals1, intervals2 = map(int, (intervals1, intervals2))
# unused: start1, end1, start2, end2, time = map(float, (start1, end1, start2, end2, time))
if len(axis1) < intervals1 and min(axis2) == max(axis2):
# stopped scan before second row started, 1-D plot is better (issue #82)
self.axes = [
label1,
]
self.signal = self.scan.column_last
self.data[label1] = self.scan.data[label1]
self.data[self.signal] = self.scan.data[self.signal]
return
axis1 = axis1[0 : intervals1 + 1]
self.data[label1] = axis1 # 1-D array
axis2 = [
axis2[row]
for row in range(len(axis2))
if row % (intervals1 + 1) == 0
]
self.data[label2] = axis2 # 1-D array
column_labels = self.scan.L
column_labels.remove(label1) # special handling
column_labels.remove(label2) # special handling
if self.scan.scanCmd.startswith("hkl"):
# find the reciprocal space axis held constant
label3 = [
key for key in ("H", "K", "L") if key in column_labels
][0]
self.data[label3] = self.scan.data.get(label3)[0] # constant
# build 2-D data objects (do not build label1, label2, [or label3] as 2-D objects)
data_shape = [len(axis2), len(axis1)]
for label in column_labels:
if label not in self.data:
axis = numpy.array(self.scan.data.get(label))
self.data[label] = utils.reshape_data(axis, data_shape)
else:
pass
self.signal = utils.clean_name(self.scan.column_last)
self.axes = [label1, label2]
if spec.MCA_DATA_KEY in self.scan.data: # 3-D array(s)
# save each spectrum
for key, spectrum in sorted(
self.scan.data[spec.MCA_DATA_KEY].items()
):
num_channels = len(spectrum[0])
data_shape.append(num_channels)
mca = numpy.array(spectrum)
data = utils.reshape_data(mca, data_shape)
channels = range(1, num_channels + 1)
ds_name = "_" + key + "_"
self.data[ds_name] = data
self.data[ds_name + "channel_"] = channels
# class NeXusPlotter(ImageMaker): # TODO: issue #92
# """
# create a plot from a NeXus HDF5 data file
# """
#
# def retrieve_plot_data(self):
# """retrieve default data from spec data file"""
# raise NotImplementedError(self.__class__.__name__ + '() is not ready')
#
# def make_image(self, plotFile):
# """
# make image file from the SPEC scan
#
# :param str plotFile: name of image file to write
# """
# raise NotImplementedError(self.__class__.__name__ + '() is not ready')
[docs]def openSpecFile(specFile):
"""
convenience routine so that others do not have to `import spec2nexus.spec`
"""
sd = spec.SpecDataFile(specFile)
return sd
def main():
import argparse
doc = __doc__.strip().splitlines()[0]
p = argparse.ArgumentParser(description=doc)
p.add_argument("specFile", help="SPEC data file name")
p.add_argument(
"scan_number", help="scan number in SPEC file", type=str
)
p.add_argument("plotFile", help="output plot file name")
args = p.parse_args()
sfile = openSpecFile(args.specFile)
scan = sfile.getScan(args.scan_number)
image_maker = Selector().auto(scan)
plotter = image_maker()
plotter.plot_scan(scan, args.plotFile)
if __name__ == "__main__":
main()