I am writing mass spec data reduction software. At this point in the script, the user is shown all data plotted together and asked to identify any outliers.
I have set up two click handlers:
on_pick()
for when the user clicks on an analysis line in any plot, to flag the analysis as an outlier and set its active status to Falseon_click()
for when the user clicks on the bottom right subplot but NOT on an analysis line, to toggle between viewing two different data plots
A less-than-minimalist reproducible example is below:
import matplotlib.pyplot as plt
import matplotlib.backends.backend_tkagg as tkagg
import tkinter as tk
import mplcursors
from matplotlib.backend_bases import MouseEvent
"""
manual quality control function
plot all data together and allow the user to click on any analysis to
identify it as an outlier and flag its status as active = False
inactive analyses are excluded from statistics and data reduction
"""
def qc_manual(filtered_data):
def update_plot_colors(axes, lines_to_analyze):
for ax in axes.flat:
for line in ax.lines:
analysis = lines_to_analyze.get(line)
line.set_color(
'blue' if analysis.active and analysis.analysis_type == 'lineblank' else
'gray' # inactive
)
line.set_marker('o' if analysis.active else 'x')
# when the user clicks on an analysis, toggle its status
def on_pick(event, filtered_data):
analysis = lines_to_analyze.get(event.artist)
if analysis:
analysis.active = not analysis.active
update_plot_colors(axes, lines_to_analyze)
canvas.draw()
# toggle the 2/40 amu data
def on_click(event, filtered_data, axes, lines_to_analyze):
if event.inaxes == axes[1, 1]:
for line in axes[1, 1].lines:
contains, __ = line.contains(event)
if contains:
return
# clear the lines and axes from the bottom right plot
axes[1, 1].clear()
lines_to_analyze = {line: analysis for line, analysis in lines_to_analyze.items() if line not in axes[1,1].lines}
title = axes[1, 1].get_title()
new_title = '2 amu - All Data' if '40 amu - All Data' in title else '40 amu - All Data'
axes[1,1].set_title(new_title)
for analysis in filtered_data:
line_color = 'blue' if analysis.active else 'gray'
line_marker = 'o' if analysis.active else 'x'
if new_title == '2 amu - All Data':
line, = axes[1,1].plot(analysis.time_sec, analysis.raw_data['2 amu'],
marker=line_marker, linestyle='-', picker=5, color=line_color, zorder=2
)
else:
line, = axes[1,1].plot(analysis.time_sec, analysis.raw_data['40 amu'],
marker=line_marker, linestyle='-', picker=5, color=line_color, zorder=2
)
# only update the relevant lines in the dictionary
if line in lines_to_analyze:
lines_to_analyze[line] = analysis
# formatting
axes[1,1].set_title(new_title)
axes[1,1].set_xlabel('Time (sec)')
axes[1,1].set_ylabel('Intensity (A)')
axes[1,1].grid(True, zorder=1)
update_plot_colors(axes, lines_to_analyze)
canvas.draw()
return lines_to_analyze
else:
return lines_to_analyze
lines_to_analyze = {} # dictionary to map lines to analyses for efficient lookup
figure = plt.figure()
# set figure size and create subplots
figure.set_size_inches(12, 8)
axes = figure.subplots(2, 2)
for ax, (mass, title) in zip(axes.flat, [
('4 amu', '4 amu - Gas Standards'),
('4 amu', '4 amu - Blanks'),
('3 amu', '3 amu - All Data'),
('40 amu', '40 amu - All Data')
]):
for analysis in filtered_data:
# formatting
line_color = 'blue' if analysis.active else 'gray'
line_marker = 'o' if analysis.active else 'x'
# only plot blanks for 4 amu
if analysis.analysis_type not in ['lineblank', 'coldblank', 'hotblank'] and title == '4 amu - Blanks':
continue
if analysis.analysis_type not in ['Q', 'D'] and title == '4 amu - Gas Standards':
continue
# draw the data
line, = ax.plot(analysis.time_sec, analysis.raw_data[mass],
marker=line_marker, linestyle='-', picker=5, color=line_color, zorder=2
)
lines_to_analyze[line] = analysis
# formatting
ax.set_title(title)
ax.set_xlabel('Time (sec)')
ax.set_ylabel('Intensity (A)')
ax.grid(True, zorder=1)
# re-embed the canvas within the main-frame
canvas = tkagg.FigureCanvasTkAgg(figure)
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
update_plot_colors(axes, lines_to_analyze)
# set up the click handler to remove analyses
canvas.mpl_connect('pick_event', lambda event: on_pick(event, filtered_data))
# set up the click handler to toggle the 2/40 amu data
canvas.mpl_connect('button_press_event',
lambda event: lines_to_analyze.update(on_click(event, filtered_data)) if isinstance(event, MouseEvent) else None
)
# create mplcursors instance and connect to hover events
cursor = mplcursors.cursor(lines_to_analyze.keys(), hover=True)
cursor.connect("add", lambda sel: sel.annotation.set_text(lines_to_analyze[sel.artist].analysis_label))
plt.tight_layout()
canvas.draw()
filtered_data_list = []
for i in range(5): # replace 5 with the number of analyses you want
filtered_data = type('filtered_data', (object,), {'raw_data': {}, 'time_sec': [], 'analysis_type': 'lineblank', 'active': True})
filtered_data.time_sec = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
filtered_data.raw_data['4 amu'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered_data.raw_data['2 amu'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered_data.raw_data['3 amu'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered_data.raw_data['40 amu'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
filtered_data.raw_data['5 amu'] = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
filtered_data_list.append(filtered_data)
qc_manual(filtered_data_list)
When I try to click on the bottom right subplot to toggle between 40 amu and 2 amu, I get the following error:
Traceback (most recent call last):
File "C:UsersatomgAppDataLocalProgramsPythonPython311Libsite-packagesmatplotlibcbook.py", line 298, in process
func(*args, **kwargs)
File "c:UsersatomgOneDrive - Thermochron Systems LLCheman_codeProtest.py", line 127, in <lambda>
lambda event: lines_to_analyze.update(on_click(event, filtered_data, axes, lines_to_analyze)) if isinstance(event, MouseEvent) else None
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "c:UsersatomgOneDrive - Thermochron Systems LLCheman_codeProtest.py", line 71, in on_click
update_plot_colors(axes, lines_to_analyze)
File "c:UsersatomgOneDrive - Thermochron Systems LLCheman_codeProtest.py", line 21, in update_plot_colors
'blue' if analysis.active and analysis.analysis_type == 'lineblank' else
^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'active'
Something in on_click()
is appending a NoneType object to lines_to_analyze
, and I can’t figure out where or why. What am I doing wrong?