My goal is to create a program that plots mass spectrometer data and allows the user to interactively click on a datum to remove it as an outlier, with “Exit”, “Previous”, “Next”, etc buttons below the plots.
Unfortunately the script only generates a blank window. I’m stumped. I’m pretty new to Python and probably doing something stupid, so any help would be greatly appreciated.
from import_raw import get_raw_data
from select_sequences import filter_data
from interactive_plot import plot_raw_data
import matplotlib.pyplot as plt
import matplotlib
import matplotlib.backends.backend_tkagg as tkagg
import tkinter as tk
def on_next():
global current_plot_index
current_plot_index = (current_plot_index + 1) % len(filtered_data)
draw_plot(current_plot_index)
update_buttons()
def on_previous():
global current_plot_index
current_plot_index = (current_plot_index - 1) % len(filtered_data)
draw_plot(current_plot_index)
update_buttons()
def on_finish():
window.quit()
def on_exit():
window.quit()
def update_buttons():
global current_plot_index
prev_button.pack_forget()
next_button.pack_forget()
finish_button.pack_forget()
if current_plot_index > 0:
prev_button.pack(side=tk.LEFT)
if current_plot_index < len(filtered_data) - 1:
next_button.pack(side=tk.RIGHT)
else:
finish_button.pack(side=tk.RIGHT)
def draw_plot(index, canvas):
figure, canvas = plot_raw_data(filtered_data[index], canvas)
figure.canvas.draw()
canvas.draw()
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
matplotlib.use('TkAgg') # forces TkAgg backend to matplotlib (for MacOSX development)
# basic functions
all_data, sequence_data = get_raw_data() # get all raw data and group into sequences
filtered_data = filter_data(all_data, sequence_data) # filter out only the selected sequences
# GUI
window = tk.Tk()
# Frame for plot and buttons
main_frame = tk.Frame(window)
main_frame.pack()
plot_frame = tk.Frame(main_frame)
plot_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
button_options = {'width': 10, 'height': 2}
button_frame = tk.Frame(main_frame)
# initialization in main frame
figure = plt.figure()
canvas = tkagg.FigureCanvasTkAgg(figure, master=plot_frame)
canvas.draw()
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
current_plot_index = 0
# initialize buttons
global prev_button, exit_button, next_button, finish_button
prev_button = tk.Button(button_frame, text="Previous", command=lambda: on_previous(), **button_options)
exit_button = tk.Button(button_frame, text="Exit", command=lambda: on_exit(), **button_options)
next_button = tk.Button(button_frame, text="Next", command=lambda: on_next(), **button_options)
finish_button = tk.Button(button_frame, text="Finish", command=lambda: on_finish(), **button_options)
prev_button.pack(side=tk.LEFT)
exit_button.pack(side=tk.LEFT)
next_button.pack(side=tk.RIGHT)
finish_button.pack(side=tk.RIGHT)
update_buttons()
draw_plot(0, canvas)
window.mainloop()
I can confirm that interactive_plot.py
(below) generates the correct figure.
import matplotlib.pyplot as plt
import numpy as np
import tkinter as tk
from tkinter import messagebox
from sklearn.preprocessing import MinMaxScaler
# this finds the closest datum to the click point
def find_closest_point(data, click_coords, mass, scaler):
scaled_data = scaler.transform(data[mass][['time_sec', mass]])
scaled_click = scaler.transform([click_coords])
x_diff = np.abs(scaled_data[:,0] - scaled_click[0, 0])
y_diff = np.abs(scaled_data[:,1] - scaled_click[0, 1])
distances = np.sqrt(x_diff**2 + y_diff**2)
closest_index = distances.argmin()
print("closest_index:", closest_index)
return closest_index
# what to do when the user clicks on the plot
def on_click(event, data_entry, fig, canvas):
click_coords = (event.xdata, event.ydata)
active_subplot = event.inaxes
mass = active_subplot.get_title()
# disallow clicks on restricted plots
if mass in ['5 amu', '4/3 Ratio']:
if mass == '5 amu':
messagebox.showinfo('Instructions', 'Removing baseline data is forbidden.')
else: # 4/3 Ratio
messagebox.showinfo('Instructions', 'Removing 4/3 Ratio data is forbidden. Remove data from 3 amu or 4 amu, rather than from the 4/3 Ratio directly.')
return # prevents further code execution/plot updates.
# produce a scale for the data so that the least squares fit works
scaler = MinMaxScaler()
scaler.fit(data_entry.raw_data[mass].values)
# determine the index of the closest datum to the click
closest_index = find_closest_point(data_entry.raw_data, click_coords, mass, scaler)
# toggle the clicked datum between 1 (active) and 0 (inactive)
data_entry.data_status[mass].iloc[closest_index] = (data_entry.data_status[mass].iloc[closest_index] + 1) % 2
# if any datum is excluded from 3 amu or 4 amu, also exclude it from the 4/3 Ratio
if mass in ['3 amu', '4 amu']:
data_entry.data_status['4/3 Ratio'].iloc[closest_index] = (data_entry.data_status['4/3 Ratio'].iloc[closest_index] + 1) % 2
update_plot_and_trendlines(data_entry, fig, canvas)
# updates the plot
def update_plot_and_trendlines(data_entry, fig):
fig.clf()
axes = fig.subplots(2,3)
# Store plot styling for each mass
plot_kwargs = {
"3 amu": {"color": "blue"},
"4 amu": {"color": "red"},
"4/3 Ratio": {"color": "purple"},
"2 amu": {"edgecolor": "black", "facecolor": "white", "linewidth": 1},
"40 amu": {"color": "magenta"},
"5 amu": {"color": "gray"}
}
# loop to plot masses on the top row
for ax, mass in zip(axes[0], ['3 amu', '4 amu', '4/3 Ratio']):
# dress up the plot
ax.set_xlabel('Time (s)')
if mass == '4/3 Ratio':
ax.set_ylabel('4/3 Ratio * 1000')
else:
ax.set_ylabel('Intensity (A)')
ax.set_title(mass)
ax.grid(True, zorder=1)
# define x and y
x = data_entry.raw_data[mass]['time_sec']
y = data_entry.raw_data[mass][mass]
# get active indices from data_status[mass]
active_indices = data_entry.data_status[mass].to_numpy().flatten()
# draw trendline/average from active indices
if mass == '4/3 Ratio':
average_y = np.mean(y[active_indices==1])
trend_x = [x.min(), x.max()]
trend_y = [average_y, average_y]
ax.plot(trend_x, trend_y, color="black", zorder=2)
else: # 3 amu and 4 amu
trend = np.polyfit(x[active_indices==1], y[active_indices==1], 1)
trend_x = [x.min(), x.max()]
trend_y = np.polyval(trend, trend_x)
ax.plot(trend_x, trend_y, color="black", zorder=2)
ax.scatter(x[active_indices==1], y[active_indices==1], **plot_kwargs[mass], zorder=3)
ax.scatter(x[active_indices==0], y[active_indices==0], marker='x', color='gray', zorder=3)
# loop to plot masses in the bottom row
for ax, mass in zip(axes[1], ['2 amu', '40 amu', '5 amu']):
# dress up the plot
ax.set_xlabel('Time (s)')
ax.set_ylabel('Intensity (A)')
ax.set_title(mass)
ax.grid(True, zorder=1)
# define x and y
x = data_entry.raw_data[mass]['time_sec']
y = data_entry.raw_data[mass][mass]
# get active and inactive indices
active_indices = data_entry.data_status[mass].to_numpy().flatten()
# draw trendline/average from active indices
if mass == '5 amu':
average_y = np.mean(y[active_indices==1]) if np.any(active_indices) else np.nan
trend_x = [x.min(), x.max()]
trend_y = [average_y, average_y]
ax.plot(trend_x, trend_y, color="black", zorder=2)
else:
trend = np.polyfit(x[active_indices==1], y[active_indices==1], 1)
trend_x = [x.min(), x.max()]
trend_y = np.polyval(trend, trend_x)
ax.plot(trend_x, trend_y, color="black", zorder=2)
# draw active and inactive plots
ax.scatter(x[active_indices==1], y[active_indices==1], **plot_kwargs[mass], zorder=3)
ax.scatter(x[active_indices==0], y[active_indices==0], marker='x', color='gray', zorder=3)
def plot_raw_data(data_entry, canvas):
fig, axes = plt.subplots(2, 3, figsize=(15, 8)) # Create 2x3 subplot grid
update_plot_and_trendlines(data_entry, fig) # draw
plt.tight_layout() # Adjust spacing to prevent labels overlapping
plt.suptitle(f"He {data_entry.helium_number}: {data_entry.analysis_label}")
plt.subplots_adjust(top=0.92)
#messagebox.showinfo('Instructions', 'Please inspect the data. Click on outliers to remove them.')
fig.canvas.mpl_connect('button_press_event', lambda event: on_click(event, data_entry, fig, canvas)) # interactivity
fig.savefig('figure.png')
return fig, canvas
ohshitgorillas is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.