Skip to content

Commit 0784297

Browse files
committed
Add robust error handling to plotter and heatmap threads
Introduces try/except blocks and disables plotting on exceptions in Plotter and Heatmap threads, logging errors and preventing further UI updates after failures. Refactors runner thread to encapsulate step logic and improve exception handling, ensuring proper cleanup and error reporting. Updates tests to skip parameter validation for mock parameters.
1 parent 1a9925d commit 0784297

File tree

4 files changed

+288
-209
lines changed

4 files changed

+288
-209
lines changed

src/measureit/_internal/plotter_thread.py

Lines changed: 99 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from PyQt5.QtGui import QFont
1010
from PyQt5.QtWidgets import QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
1111

12+
from ..logging_utils import get_sweep_logger
13+
1214
# Configure PyQtGraph for better performance
1315
pg.setConfigOptions(
1416
antialias=False, # Disable antialiasing for better performance
@@ -82,6 +84,7 @@ def __init__(self, sweep, plot_bin=1):
8284
self.last_pass = False
8385
self.figs_set = False
8486
self.kill_flag = False
87+
self.logger = getattr(sweep, "logger", None) or get_sweep_logger("plotter")
8588

8689
# PyQtGraph-specific attributes
8790
self.widget = None
@@ -284,7 +287,11 @@ def create_figs(self):
284287
self.widget.closeEvent = self.handle_close
285288

286289
# Show the widget
287-
self.widget.show()
290+
try:
291+
self.widget.show()
292+
except Exception as e:
293+
self._disable_plotting(f"Plotter widget failed to show: {e}")
294+
return
288295

289296
@pyqtSlot(object, int)
290297
def add_data(self, data, direction):
@@ -297,11 +304,16 @@ def add_data(self, data, direction):
297304
direction:
298305
The direction of the sweep (0 or 1).
299306
"""
300-
self.data_queue.append((data, direction))
307+
if self.kill_flag:
308+
return
309+
try:
310+
self.data_queue.append((data, direction))
301311

302-
# Only update plots when we have enough data points or it's been a while
303-
# This prevents excessive update calls that slow down the plotting
304-
self.update_plots(force=data is None)
312+
# Only update plots when we have enough data points or it's been a while
313+
# This prevents excessive update calls that slow down the plotting
314+
self.update_plots(force=data is None)
315+
except Exception as e:
316+
self._disable_plotting(f"Plotter add_data failed: {e}")
305317

306318
@pyqtSlot(bool)
307319
def update_plots(self, force=False):
@@ -313,79 +325,83 @@ def update_plots(self, force=False):
313325
force:
314326
If True, process all queued data immediately.
315327
"""
316-
if not self.figs_set:
317-
return
318-
319-
# Check if we should update - either we have enough data or force is True
320-
if len(self.data_queue) < self.plot_bin and not force:
328+
if not self.figs_set or self.kill_flag:
321329
return
322-
323-
# Process all queued data at once for better performance
324-
while len(self.data_queue) > 0:
325-
temp = self.data_queue.popleft()
326-
if temp[0] is None:
327-
break
328-
data = deque(temp[0])
329-
direction = temp[1]
330-
331-
# Get time data
332-
time_data = data.popleft()
333-
334-
# Handle set parameter plot
335-
set_param_data = None
336-
if self.sweep.set_param is not None:
337-
set_param_data = data.popleft()
338-
339-
# Add to set parameter data arrays
340-
# Ensure scalars by flattening any arrays
341-
time_val = time_data[1]
342-
if hasattr(time_val, "flatten"):
343-
time_val = float(np.array(time_val).flatten()[0])
344-
345-
set_val = set_param_data[1]
346-
if hasattr(set_val, "flatten"):
347-
set_val = float(np.array(set_val).flatten()[0])
348-
349-
self.set_data_arrays["x"].append(time_val)
350-
self.set_data_arrays["y"].append(set_val)
351-
352-
# Determine x-axis data for followed parameters
353-
x_data_value = (
354-
time_data[1]
355-
if self.sweep.x_axis == 1
356-
else (
357-
set_param_data[1]
358-
if self.sweep.set_param is not None
359-
else time_data[1]
330+
try:
331+
# Check if we should update - either we have enough data or force is True
332+
if len(self.data_queue) < self.plot_bin and not force:
333+
return
334+
335+
# Process all queued data at once for better performance
336+
while len(self.data_queue) > 0:
337+
temp = self.data_queue.popleft()
338+
if temp[0] is None:
339+
break
340+
data = deque(temp[0])
341+
direction = temp[1]
342+
343+
# Get time data
344+
time_data = data.popleft()
345+
346+
# Handle set parameter plot
347+
set_param_data = None
348+
if self.sweep.set_param is not None:
349+
set_param_data = data.popleft()
350+
351+
# Add to set parameter data arrays
352+
# Ensure scalars by flattening any arrays
353+
time_val = time_data[1]
354+
if hasattr(time_val, "flatten"):
355+
time_val = float(np.array(time_val).flatten()[0])
356+
357+
set_val = set_param_data[1]
358+
if hasattr(set_val, "flatten"):
359+
set_val = float(np.array(set_val).flatten()[0])
360+
361+
self.set_data_arrays["x"].append(time_val)
362+
self.set_data_arrays["y"].append(set_val)
363+
364+
# Determine x-axis data for followed parameters
365+
x_data_value = (
366+
time_data[1]
367+
if self.sweep.x_axis == 1
368+
else (
369+
set_param_data[1]
370+
if self.sweep.set_param is not None
371+
else time_data[1]
372+
)
360373
)
361-
)
362-
# Ensure x_data_value is scalar
363-
if hasattr(x_data_value, "flatten"):
364-
x_data_value = float(np.array(x_data_value).flatten()[0])
374+
# Ensure x_data_value is scalar
375+
if hasattr(x_data_value, "flatten"):
376+
x_data_value = float(np.array(x_data_value).flatten()[0])
365377

366-
# Add data to arrays for followed parameters
367-
for i, data_pair in enumerate(data):
368-
param = self.sweep._params[i]
378+
# Add data to arrays for followed parameters
379+
for i, data_pair in enumerate(data):
380+
param = self.sweep._params[i]
369381

370-
if param in self.data_arrays:
371-
direction_key = "forward" if direction == 0 else "backward"
382+
if param in self.data_arrays:
383+
direction_key = "forward" if direction == 0 else "backward"
372384

373-
# Ensure y_data is scalar
374-
y_value = data_pair[1]
375-
if hasattr(y_value, "flatten"):
376-
y_value = float(np.array(y_value).flatten()[0])
385+
# Ensure y_data is scalar
386+
y_value = data_pair[1]
387+
if hasattr(y_value, "flatten"):
388+
y_value = float(np.array(y_value).flatten()[0])
377389

378-
self.data_arrays[param][direction_key]["x"].append(x_data_value)
379-
self.data_arrays[param][direction_key]["y"].append(y_value)
390+
self.data_arrays[param][direction_key]["x"].append(x_data_value)
391+
self.data_arrays[param][direction_key]["y"].append(y_value)
380392

381-
# Now update all plots at once (much more efficient)
382-
self._update_plot_displays()
383-
self.update_progress_widgets()
393+
# Now update all plots at once (much more efficient)
394+
self._update_plot_displays()
395+
self.update_progress_widgets()
396+
except Exception as e:
397+
self._disable_plotting(f"Plotter update_plots failed: {e}")
384398

385399
def _update_plot_displays(self):
386400
"""Efficiently updates all plot displays using stored data arrays.
387401
Optimized for large datasets - converts to numpy arrays efficiently.
388402
"""
403+
if self.kill_flag:
404+
return
389405
# Update set parameter plot
390406
if self.sweep.set_param is not None and self.set_plot_item is not None:
391407
if self.set_data_arrays["x"] and self.set_data_arrays["y"]:
@@ -497,6 +513,23 @@ def get_plot_data(self, param_index):
497513
}
498514
return None
499515

516+
def _disable_plotting(self, reason: str):
517+
"""Disable plotting after an exception and log the error."""
518+
try:
519+
self.logger.error(reason, exc_info=True)
520+
except Exception:
521+
pass
522+
self.kill_flag = True
523+
try:
524+
self.sweep.plot_data = False
525+
except Exception:
526+
pass
527+
try:
528+
if self.figs_set:
529+
self.clear()
530+
except Exception:
531+
pass
532+
500533
@pyqtSlot()
501534
def run(self):
502535
"""Creates figures if they have not already been set."""

src/measureit/_internal/runner_thread.py

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,32 @@ def add_plotter(self, plotter):
120120
self.plotter = plotter
121121
self.send_data.connect(self.plotter.add_data)
122122

123+
def _run_step(self) -> bool:
124+
"""Run a single sweep step; return False when the loop should exit."""
125+
t = time.monotonic()
126+
state = getattr(self.sweep.progressState, "state", None)
127+
128+
if state == SweepState.RUNNING:
129+
data = self.sweep.update_values()
130+
self.sweep.update_progress()
131+
132+
if self.plotter is not None and self.sweep.plot_data is True:
133+
self.send_data.emit(data, self.sweep.direction)
134+
135+
# Smart sleep: compensate for time spent executing update
136+
sleep_time = self.sweep.inter_delay - (time.monotonic() - t)
137+
if sleep_time > 0:
138+
time.sleep(sleep_time)
139+
140+
# Refresh state after possible updates
141+
state = getattr(self.sweep.progressState, "state", None)
142+
if state in (SweepState.DONE, SweepState.KILLED, SweepState.ERROR):
143+
if self.sweep.save_data is True and self.datasaver is not None:
144+
self.datasaver.flush_data_to_database()
145+
return False
146+
147+
return True
148+
123149
def _set_parent(self, sweep):
124150
"""Sets a parent sweep if the Runner Thread is created independently.
125151
@@ -197,49 +223,42 @@ def run(self):
197223
return # Exit run() early - no point entering the main loop
198224

199225
# print(f"called runner from thread: {QThread.currentThreadId()}")
200-
while True:
201-
t = time.monotonic()
202-
state = getattr(self.sweep.progressState, "state", None)
203-
204-
data = None
205-
if state == SweepState.RUNNING:
226+
try:
227+
while True:
206228
try:
207-
data = self.sweep.update_values()
229+
should_continue = self._run_step()
208230
except ParameterException as e:
209231
# safe_set already retried once and gave up - immediately transition to ERROR
210232
# This prevents the sweep from continuing to the next setpoint with bad data
211233
# Don't emit completed signal here - defer it until after loop exits
212234
# to avoid blocking the main event loop
213235
self.sweep.mark_error(
214236
f"Parameter operation failed: {e}",
215-
_from_runner=True
237+
_from_runner=True,
216238
)
217239
break # Exit loop immediately to avoid race conditions
218-
self.sweep.update_progress()
219-
220-
if (
221-
self.plotter is not None
222-
and self.sweep.plot_data is True
223-
):
224-
self.send_data.emit(data, self.sweep.direction)
225-
226-
# Smart sleep: compensate for time spent executing update
227-
sleep_time = self.sweep.inter_delay - (time.monotonic() - t)
228-
229-
if sleep_time > 0:
230-
time.sleep(sleep_time)
231-
232-
# Refresh state after possible updates
233-
state = getattr(self.sweep.progressState, "state", None)
234-
if state in (SweepState.DONE, SweepState.KILLED, SweepState.ERROR):
235-
if self.sweep.save_data is True and self.datasaver is not None:
236-
self.datasaver.flush_data_to_database()
237-
if state in (SweepState.KILLED, SweepState.ERROR):
240+
except (KeyboardInterrupt, SystemExit):
241+
raise
242+
except Exception as e:
243+
# Catch-all: ensure unexpected exceptions mark the sweep as ERROR
244+
logger.error(
245+
"Unhandled exception in runner thread: %s\n%s",
246+
e,
247+
traceback.format_exc(),
248+
)
249+
try:
250+
self.sweep.mark_error(
251+
f"Unhandled runner error: {e}",
252+
_from_runner=True,
253+
)
254+
except Exception:
255+
pass
238256
break
239-
# Allow DONE sweeps to exit after flushing
240-
break
241257

242-
self.exit_datasaver()
258+
if not should_continue:
259+
break
260+
finally:
261+
self.exit_datasaver()
243262

244263
# Emit completed signal for ERROR state after loop exits
245264
# This is deferred to avoid blocking the main event loop during exception handling
@@ -249,8 +268,14 @@ def run(self):
249268

250269
def exit_datasaver(self):
251270
if self.datasaver is not None:
252-
if self.sweep.save_data is True:
253-
self.datasaver.flush_data_to_database()
254-
255-
self.runner.__exit__(None, None, None)
271+
try:
272+
if self.sweep.save_data is True:
273+
self.datasaver.flush_data_to_database()
274+
except Exception:
275+
logger.error("Failed to flush datasaver:\n%s", traceback.format_exc())
276+
try:
277+
if self.runner is not None:
278+
self.runner.__exit__(None, None, None)
279+
except Exception:
280+
logger.error("Failed to close datasaver:\n%s", traceback.format_exc())
256281
self.datasaver = None

0 commit comments

Comments
 (0)