fix return-value/deprecated API bugs, accumulation lock leaks, code cleanup
Build Debian Packages / build (bookworm, debian12) (push) Successful in 8m56s
Build Debian Packages / build (trixie, debian13) (push) Successful in 9m3s

- raise instead of silent None for unsupported operands and empty negation
- validate before acquiring lock in Accumulation operators (no RLock leak)
- Accumulation._copy_provenance helper, ADC_Result._copy_metadata for all result paths
- shared _sandbox_stdout_redirect contextmanager for sandboxed scripts
- TemperatureMonitor dead code removal (gtk helper, traceback, unused properties)
- new unit tests incl. lock-freedom regression test
- gitignore sphinx generated doc/_autosummary/ and doc/damaris/
This commit is contained in:
markusro committed 2026-10-17 19:17:43 +02:00
1 parent 91ade4376c
commit f9d8de7697
26 files changed
+590 -236

No files matched your search

+18 -4
View File
@@ -232,8 +232,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
job_id=self.job_id,
job_date=self.job_date,
)
r.nChannels = self.nChannels
r.is_clipped = self.is_clipped
self._copy_metadata(r)
finally:
self.lock.release()
@@ -277,8 +276,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(self.y[i][start:end+1].copy())
r = ADC_Result(x = tmp_x, y = tmp_y, index = [(0,len(tmp_y[0])-1)], sampl_freq = self.sampling_rate, desc = self.description.copy() if self.description is not None else None, job_id = self.job_id, job_date = self.job_date)
r.nChannels = self.nChannels
r.is_clipped = self.is_clipped
self._copy_metadata(r)
self.lock.release()
return r
@@ -488,6 +486,11 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
return tmp_string
def _copy_metadata(self, r):
"Copy channel metadata (nChannels, is_clipped) from self to the new result r"
r.nChannels = self.nChannels
r.is_clipped = self.is_clipped
def __add__(self, other):
"Redefining self + other (scalar)"
if isinstance(other, int) or isinstance(other, float):
@@ -498,6 +501,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(self.y[i], dtype="float32") + other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -520,6 +524,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(self.y[i], dtype="float32") - other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -537,6 +542,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(other - numpy.array(self.y[i], dtype="float32"))
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -554,6 +560,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(self.y[i], dtype="float32") * other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -575,6 +582,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(self.y[i], dtype="float32") ** other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -589,6 +597,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(self.y[i], dtype="float32") / other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -602,6 +611,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
for i in range(self.get_number_of_channels()):
tmp_y.append(other / numpy.array(self.y[i], dtype="float32"))
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -618,6 +628,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
for i in range(self.get_number_of_channels()):
tmp_y.append(numpy.array(self.y[i], dtype="float32") // other)
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -634,6 +645,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
for i in range(self.get_number_of_channels()):
tmp_y.append(other // numpy.array(self.y[i], dtype="float32"))
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
else:
@@ -649,6 +661,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
tmp_y.append(numpy.array(-self.y[i]))
r = ADC_Result(x = self.x[:], y = tmp_y, index = self.index[:], sampl_freq = self.sampling_rate, desc = self.description, job_id = self.job_id, job_date = self.job_date)
self._copy_metadata(r)
self.lock.release()
return r
@@ -703,6 +716,7 @@ def read_from_hdf(hdf_node):
adc.cont_data=True
# now do the real data
adc_data=hdf_node.adc_data.read()
adc.set_nChannels(adc_data.shape[1])
adc.x=numpy.arange(adc_data.shape[0], dtype="float32")/adc.sampling_rate
+36 -26
View File
@@ -102,6 +102,14 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
else:
raise ValueError("Wrong usage of __init__!")
def _copy_provenance(self, r):
r.is_clipped = self.is_clipped
r.job_id = self.job_id
r.job_ids = self.job_ids.copy()
r.time_period = self.time_period[:] if self.time_period is not None else None
if self.common_descriptions is not None:
r.common_descriptions = self.common_descriptions.copy()
def get_accu_by_index(self, index: int):
self.lock.acquire()
try:
@@ -637,12 +645,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, y_2 = tmp_ysquare, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = self.use_error)
else:
r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = self.use_error)
r.is_clipped = self.is_clipped
r.job_id = self.job_id
r.job_ids = self.job_ids.copy()
r.time_period = self.time_period[:] if self.time_period is not None else None
if self.common_descriptions is not None:
r.common_descriptions = self.common_descriptions.copy()
self._copy_provenance(r)
self.lock.release()
return r
@@ -678,8 +681,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# Other and self not empty (self + other ADC_Result)
else:
self.lock.acquire()
if self.sampling_rate != other.get_sampling_rate():
raise ValueError("Accumulation: You cant add ADC_Results with different sampling-rates")
if len(self.y[0]) != len(other):
@@ -690,6 +691,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
if self.index[i] != other.get_index_bounds(i):
raise ValueError("Accumulation: You cant add ADC_Results with different indexing")
self.lock.acquire()
tmp_y = []
tmp_ysquare = []
@@ -706,7 +709,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
r.time_period=[min(self.time_period[0],other.job_date),
max(self.time_period[1],other.job_date)]
r.job_id = other.job_id
r.job_ids = self.job_ids
r.job_ids = self.job_ids.copy()
r.job_ids[other.job_id] = other.get_job_date()
if self.common_descriptions is not None:
@@ -725,33 +728,29 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# Self empty (copy)
if not self.contains_data():
if self.uses_statistics() and not other.uses_statistics():
raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error")
self.lock.acquire()
tmp_y = []
tmp_ysquare = []
self.lock.acquire()
for i in range(other.get_number_of_channels()):
tmp_y.append(numpy.array(other.y[i], dtype="float32"))
if other.uses_statistics():
tmp_ysquare.append(numpy.array(other.y_square[i], dtype="float32"))
if self.uses_statistics():
r = Accumulation(x = numpy.array(other.x, dtype="float32"), y = tmp_y, y_2 = tmp_ysquare, n = other.n, index = other.index, sampl_freq = other.sampling_rate, error = True)
else:
r = Accumulation(x = numpy.array(other.x, dtype="float32"), y = tmp_y, n = other.n, index = other.index, sampl_freq = other.sampling_rate, error = False)
for i in range(other.get_number_of_channels()):
tmp_y.append(other.y[i])
tmp_ysquare.append(other.y_square[i])
r.time_period=other.time_period[:]
r.job_id = other.job_id
r.job_ids = other.job_ids
if other.common_descriptions is not None:
r.common_descriptions=other.common_descriptions.copy()
else:
r.common_descriptions=None
other._copy_provenance(r)
self.lock.release()
return r
# Other and self not empty (self + other)
else:
self.lock.acquire()
if self.sampling_rate != other.get_sampling_rate():
raise ValueError("Accumulation: You cant add accumulations with different sampling-rates")
if len(self.y[0]) != len(other):
@@ -764,6 +763,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
if self.uses_statistics() and not other.uses_statistics():
raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error")
self.lock.acquire()
tmp_y = []
tmp_ysquare = []
@@ -780,6 +781,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
r.time_period=[min(self.time_period[0],other.time_period[0]),
max(self.time_period[1],other.time_period[1])]
r.job_id = other.job_id
r.job_ids = self.job_ids.copy()
for i in other.job_ids:
r.job_ids[i] = other.job_ids[i]
r.common_descriptions={}
@@ -793,6 +795,12 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
return r
elif other is None:
# Convenience: ignore add of None
return self + 0
else:
raise ValueError("can not add "+repr(type(other))+" to Accumulation")
def __radd__(self, other):
"""Redefining other + self"""
return self.__add__(other)
@@ -992,8 +1000,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# Other and self not empty (self + other/ADC_Result)
else:
self.lock.acquire()
if self.sampling_rate != other.get_sampling_rate():
raise ValueError("Accumulation: You can't add ADC-Results with different sampling-rates")
if len(self.y[0]) != len(other):
@@ -1004,6 +1010,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
if self.index[i] != other.get_index_bounds(i):
raise ValueError("Accumulation: You can't add ADC-Results with different indexing")
self.lock.acquire()
for i in range(self.get_number_of_channels()):
self.y[i] += other.y[i]
if self.uses_statistics():
@@ -1062,7 +1070,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# Other and self not empty (self + other)
else:
self.lock.acquire()
if self.sampling_rate != other.get_sampling_rate():
raise ValueError("Accumulation: You cant add accumulations with different sampling-rates")
if len(self.y[0]) != len(other):
@@ -1074,6 +1081,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
raise ValueError("Accumulation: You cant add accumulations with different indexing")
if self.uses_statistics() and not other.uses_statistics():
raise ValueError("Accumulation: You cant add non-error accumulations to accumulations with error")
self.lock.acquire()
for i in range(self.get_number_of_channels()):
self.y[i] += other.y[i]
@@ -1123,7 +1132,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
"""Redefining -self"""
if not self.contains_data():
return
raise ValueError("Accumulation: You cant negate an empty accumulation")
tmp_y = []
@@ -1135,6 +1144,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, y_2 = numpy.array(self.y_square), n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = True)
else:
r = Accumulation(x = numpy.array(self.x, dtype="float32"), y = tmp_y, n = self.n, index = self.index, sampl_freq = self.sampling_rate, error = False)
self._copy_provenance(r)
self.lock.release()
return r
+4
View File
@@ -17,8 +17,12 @@ class Config_Result(Resultable):
if config is None:
self.config = { }
else:
self.config = config
if desc is None:
self.description = { }
else:
self.description = desc
self.job_id = job_id
self.job_date = job_date
+4 -4
View File
@@ -19,10 +19,6 @@ class DamarisFFT:
:param float stop: end of clipping in s
"""
# check if start/stop order is properly
if start > stop:
start, stop = stop, start
# do nothing if one uses clip as a "placeholder"
if start is None and stop is None:
return self
@@ -32,6 +28,10 @@ class DamarisFFT:
if stop is None:
stop = self.x[ -1 ]
# check if start/stop order is properly
if start > stop:
start, stop = stop, start
# check if data is fft which changes the start/stop units
if self.xlabel == "Frequency / Hz":
start = self.x.size * (0.5 + start / self.sampling_rate)
+4 -1
View File
@@ -155,7 +155,10 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
def __add__(self, right_value):
if right_value==0:
return self.copy()
new_one=MeasurementResult(self.quantity_name)
for key in self.data:
new_one[key]=self.data[key].copy()
return new_one
else:
raise Exception("not implemented")
+3 -3
View File
@@ -21,16 +21,16 @@ class Signalpath:
if abs(reduced_angle[1]) < 1e-6:
reduced_angle = reduced_angle[0] % 4
if reduced_angle == 0:
rotated.y = list(self.y)
rotated.y = [a.copy() for a in self.y]
elif reduced_angle == 1:
rotated.y = [-self.y[1], self.y[0]]
rotated.y = [-self.y[1], self.y[0].copy()]
elif reduced_angle == 2:
rotated.y = [-self.y[0], -self.y[1]]
elif reduced_angle == 3:
rotated.y = [self.y[1], -self.y[0]]
rotated.y = [self.y[1].copy(), -self.y[0]]
else:
rad_angle = N.deg2rad(degrees)
sin_angle = N.sin(rad_angle)
+3 -3
View File
@@ -120,7 +120,7 @@ class BackendDriver(threading.Thread):
self.statefilename=None
state_files=glob.glob(os.path.join(self.spool_dir,"*.state"))
while len(state_files)==0:
if timeout<0 or self.core_input is None or self.core_input.poll() is not None or self.quit_flag.isSet():
if timeout<0 or self.core_input is None or self.core_input.poll() is not None or self.quit_flag.is_set():
# look into core log file and include contents
logger.error("Backend failed to start: timeout=%s poll=%s", timeout, self.core_input.poll())
log_message=''
@@ -156,10 +156,10 @@ class BackendDriver(threading.Thread):
break
# wait on flag and look after backend
while not self.quit_flag.isSet() and self.is_busy():
while not self.quit_flag.is_set() and self.is_busy():
self.quit_flag.wait(0.1)
if self.quit_flag.isSet():
if self.quit_flag.is_set():
self.stop_queue()
while self.is_busy():
time.sleep(0.1)
+76 -92
View File
@@ -52,6 +52,31 @@ from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCan
max_points_to_display = 0
def apply_font_css(widget, font_string):
"""
Non-deprecated replacement for the deprecated widget.modify_font():
converts a pango font description string (family, optional size in pt,
optional weight) into a CSS rule applied to the widget's style context.
Examples:
>>> apply_font_css(textview, "Monospace 10")
>>> apply_font_css(label, "sans bold 16")
"""
fd = pango.FontDescription(font_string)
decl = "font-family: %s;" % fd.get_family()
if fd.get_size() > 0 and not fd.get_size_is_absolute():
decl += " font-size: %.6gpt;" % (fd.get_size() / float(pango.SCALE))
if fd.get_weight() != pango.Weight.NORMAL:
decl += " font-weight: %d;" % fd.get_weight()
provider = gtk.CssProvider()
provider.load_from_data(("%s { %s }" % (widget.get_css_name(), decl)).encode("utf-8"))
context = widget.get_style_context()
context.add_provider(provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
# keep a reference so the provider is not garbage collected
setattr(widget, "_damaris_font_css_provider", provider)
import matplotlib.axes
import matplotlib.figure
@@ -267,12 +292,10 @@ class DamarisGUI:
def run( self ):
# prolong lifetime of clipboard till the very end (avoid error message)
self.main_clipboard = self.sw.main_clipboard
gdk.threads_enter( )
if self.start_immediately:
self.toolbar_run_button.emit("clicked")
gtk.main( )
gdk.threads_leave( )
self.si = None
self.sw = None
@@ -822,14 +845,12 @@ class DamarisGUI:
dump_size = os.stat( self.dump_filename ).st_size / 1e6
logger.info("Done (%.1f s, %.1f MB)", time.time( ) - self.dump_start_time, dump_size)
gdk.threads_enter( )
if e_text:
self.experiment_script_statusbar_label.set_text( e_text )
if r_text:
self.data_handling_statusbar_label.set_text( r_text )
if b_text:
self.backend_statusbar_label.set_text( b_text )
gdk.threads_leave( )
still_running = [_f for _f in [ self.si.exp_handling, self.si.res_handling, self.si.back_driver, self.dump_thread ] if _f]
if len( still_running ) == 0:
@@ -838,9 +859,7 @@ class DamarisGUI:
if self.save_thread is None and self.dump_filename != "":
logger.info("All subprocesses ended, saving data pool")
# Disable run button during data saving to prevent UI confusion
gdk.threads_enter()
self.toolbar_run_button.set_sensitive(False)
gdk.threads_leave()
# thread to save data...
self.save_thread = threading.Thread( target=self.dump_states, name="dump states" )
@@ -850,10 +869,8 @@ class DamarisGUI:
self.state = DamarisGUI.Stop_State
if self.state == DamarisGUI.Stop_State:
gdk.threads_enter( )
self.toolbar_pause_button.set_sensitive( False )
self.toolbar_stop_button.set_sensitive( False )
gdk.threads_leave( )
if len( still_running ) != 0:
logger.warning("Subprocess(es) still running: %s", ', '.join( [s.getName( ) for s in still_running] ))
return True
@@ -878,12 +895,10 @@ class DamarisGUI:
# now everything is stopped
self.state = DamarisGUI.Edit_State
gdk.threads_enter( )
self.sw.enable_editing( )
self.toolbar_run_button.set_sensitive( True )
self.toolbar_stop_button.set_sensitive( False )
self.toolbar_pause_button.set_sensitive( False )
gdk.threads_leave( )
return False
# dump states?
@@ -1334,8 +1349,7 @@ class LogWindow:
self.damaris_gui = damaris_gui
self.textview = self.xml_gui.get_object( "messages_textview" )
self.textview.connect( "key-press-event", self.textview_keypress)
font_desc = pango.FontDescription("Monospace 10")
self.textview.modify_font(font_desc)
apply_font_css(self.textview, "Monospace 10")
self.textbuffer = self.textview.get_buffer( )
self.last_timetag = None
self( "Started in directory %s\n" % os.getcwd( ) )
@@ -1351,11 +1365,9 @@ class LogWindow:
timetag_int = math.floor( timetag )
timetag_ms = int( (timetag - timetag_int) * 1000 )
date_tag = time.strftime( "%Y%m%d %H:%M:%S.%%03d:\n", time.localtime( timetag_int ) ) % timetag_ms
gdk.threads_enter( )
self.textbuffer.place_cursor( self.textbuffer.get_end_iter( ) )
self.textbuffer.insert_at_cursor( date_tag + str( message ) )
self.textview.scroll_to_mark( self.textbuffer.get_insert( ), 0.1 , False, 0.5, 0.5)
gdk.threads_leave( )
def textview_keypress( self, widget, event, data=None ):
if event.state & gdk.ModifierType.CONTROL_MASK != 0:
@@ -1449,22 +1461,20 @@ class ScriptWidgets:
self.data_handling_textbuffer.set_language(langpython)
self.data_handling_textbuffer.set_highlight_syntax(True)
fontdesc = pango.FontDescription("monospace")
self.experiment_script_textview.set_buffer( self.experiment_script_textbuffer )
self.experiment_script_textview.set_show_line_numbers(True)
self.experiment_script_textview.set_auto_indent(True)
self.experiment_script_textview.set_insert_spaces_instead_of_tabs(True)
self.experiment_script_textview.set_tab_width(4)
self.experiment_script_textview.set_smart_home_end(True)
self.experiment_script_textview.modify_font(fontdesc)
apply_font_css(self.experiment_script_textview, "monospace")
self.data_handling_textview.set_buffer( self.data_handling_textbuffer )
self.data_handling_textview.set_show_line_numbers(True)
self.data_handling_textview.set_auto_indent(True)
self.data_handling_textview.set_insert_spaces_instead_of_tabs(True)
self.data_handling_textview.set_tab_width(4)
self.data_handling_textview.set_smart_home_end(True)
self.data_handling_textview.modify_font(fontdesc)
apply_font_css(self.data_handling_textview, "monospace")
keywords = """for if else elif in print try finally except global lambda not or pass def
class import from as return yield while continue break assert None True False AccumulatedValue
@@ -1918,7 +1928,6 @@ get_ylabel set_ylabel"""
parent_window = self.xml_gui.get_object( "main_window" )
dialog = gtk.FileChooserDialog( title=open_dialog_title,
parent=parent_window,
action=gtk.FileChooserAction.OPEN,
buttons=("_Open", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL)
)
dialog.set_default_response( gtk.ResponseType.OK )
@@ -1944,7 +1953,6 @@ get_ylabel set_ylabel"""
parent_window = self.xml_gui.get_object( "main_window" )
dialog = gtk.FileChooserDialog( title="Open HDF5 File...",
parent=parent_window,
action=gtk.FileChooserAction.OPEN,
buttons=("_Open", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL)
)
dialog.set_default_response( gtk.ResponseType.OK )
@@ -2106,8 +2114,8 @@ get_ylabel set_ylabel"""
parent_window = self.xml_gui.get_object( "main_window" )
dialog = gtk.FileChooserDialog( title=dialog_title,
parent=parent_window,
action=gtk.FileChooserAction.SAVE,
buttons=("_Save", gtk.ResponseType.OK, "_Cancel", gtk.ResponseType.CANCEL) )
dialog.set_property("action", gtk.FileChooserAction.SAVE)
dialog.set_default_response( gtk.ResponseType.OK )
dialog.set_select_multiple( False )
@@ -2671,11 +2679,9 @@ pygobject version %(pygobject)s
title="Select Temperature Controller Device",
parent=None,
action=gtk.FileChooserAction.OPEN,
buttons=(
gtk.STOCK_CANCEL, gtk.ResponseType.CANCEL,
gtk.STOCK_OPEN, gtk.ResponseType.OK
)
)
dialog.add_button("Cancel", gtk.ResponseType.CANCEL)
dialog.add_button("Open", gtk.ResponseType.OK)
# Set default folder to /dev/serial/by-id/ if it exists
default_folder = "/dev/serial/by-id/"
@@ -2767,10 +2773,10 @@ pygobject version %(pygobject)s
font = self.config_script_font_button.get_font_name( )
experiment_script_textview = self.xml_gui.get_object( "experiment_script_textview" )
if experiment_script_textview:
experiment_script_textview.modify_font( pango.FontDescription( font ) )
apply_font_css(experiment_script_textview, font)
data_handling_textview = self.xml_gui.get_object( "data_handling_textview" )
if data_handling_textview:
data_handling_textview.modify_font( pango.FontDescription( font ) )
apply_font_css(data_handling_textview, font)
def browse_backend_executable_dialog( self, widget ):
"""
@@ -3031,7 +3037,7 @@ class MonitorWidgets:
try:
# The coordinate label is stored in the toolbar's message attribute
if hasattr(self.matplot_toolbar, 'message'):
self.matplot_toolbar.message.modify_font(pango.FontDescription("sans bold 16"))
apply_font_css(self.matplot_toolbar.message, "sans bold 16")
except:
pass
@@ -3214,50 +3220,46 @@ class MonitorWidgets:
register a listener and save reference to data
assume to be in gtk/gdk lock
"""
gdk.threads_enter( )
try:
if self.data_pool is not None:
# maybe some extra cleanup needed
if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ], "unregister_listener" ):
self.displayed_data[ 1 ].unregister_listener( self.datastructures_listener )
self.displayed_data[ 1 ] = None
self.displayed_data = [ None, None ]
self.data_pool.unregister_listener( self.datapool_listener )
self.data_pool = None
self.update_counter_lock.acquire( )
self.update_counter = 0
self.update_counter_lock.release( )
# display states
self.source_list_reset( )
self.__rescale = True
if self.data_pool is not None:
# maybe some extra cleanup needed
if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ], "unregister_listener" ):
self.displayed_data[ 1 ].unregister_listener( self.datastructures_listener )
self.displayed_data[ 1 ] = None
self.displayed_data = [ None, None ]
self.display_source_path_label.set_label( "" )
self.clear_display( )
self.data_pool.unregister_listener( self.datapool_listener )
self.data_pool = None
if data_pool is not None:
# keep track of data
self.data_pool = data_pool
self.data_pool.register_listener( self.datapool_listener )
# Populate source list with existing data since the listener
# was registered after the data was loaded
keys = sorted([k for k in data_pool if k != "__builtins__"])
for key in keys:
self.source_list_add(key)
# Select first data item if available
keys = [k for k in data_pool if not k.startswith("__")]
if keys:
keys.sort()
first_key = keys[0]
first_iter = self.source_list_find(first_key.split("/"))
if first_iter:
self.display_source_combobox.set_active_iter(first_iter)
finally:
gdk.threads_leave( )
self.update_counter_lock.acquire( )
self.update_counter = 0
self.update_counter_lock.release( )
# display states
self.source_list_reset( )
self.__rescale = True
self.displayed_data = [ None, None ]
self.display_source_path_label.set_label( "" )
self.clear_display( )
if data_pool is not None:
# keep track of data
self.data_pool = data_pool
self.data_pool.register_listener( self.datapool_listener )
# Populate source list with existing data since the listener
# was registered after the data was loaded
keys = sorted([k for k in data_pool if k != "__builtins__"])
for key in keys:
self.source_list_add(key)
# Select first data item if available
keys = [k for k in data_pool if not k.startswith("__")]
if keys:
keys.sort()
first_key = keys[0]
first_iter = self.source_list_find(first_key.split("/"))
if first_iter:
self.display_source_combobox.set_active_iter(first_iter)
#################### observing data structures and produce idle events
@@ -3348,11 +3350,7 @@ class MonitorWidgets:
if self.update_counter > 10:
logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter)
else:
gdk.threads_enter( )
try:
self.update_display( )
finally:
gdk.threads_leave( )
self.update_display( )
else:
# unregister old one
if self.displayed_data[ 1 ] is not None and hasattr( self.displayed_data[ 1 ],
@@ -3366,20 +3364,13 @@ class MonitorWidgets:
if self.update_counter > 10:
logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter)
else:
gdk.threads_enter( )
try:
self.renew_display( )
finally:
gdk.threads_leave( )
self.renew_display( )
new_data_struct = None
elif event.what == DataPool.Event.new_key:
# update combo-box by inserting and rely on consistent information
gdk.threads_enter( )
self.source_list_add( event.subject )
gdk.threads_leave( )
elif event.what == DataPool.Event.deleted_key:
# update combo-box by removing and rely on consistent information
gdk.threads_enter( )
if (self.displayed_data[ 0 ] is not None and
self.displayed_data[ 0 ] == event.subject):
self.displayed_data = [ None, None ]
@@ -3389,13 +3380,10 @@ class MonitorWidgets:
# not necessary, because event will be submitted
#self.clear_display()
self.source_list_remove( event.subject )
gdk.threads_leave( )
elif event.what == DataPool.Event.destroy:
gdk.threads_enter( )
self.source_list_reset( )
self.displayed_data = [ None, None ]
self.clear_display( )
gdk.threads_leave( )
return
def update_display_idle_event( self, subject=None ):
@@ -3409,11 +3397,7 @@ class MonitorWidgets:
return
if self.displayed_data[ 0 ] is None or subject != self.displayed_data[ 0 ]:
return
gdk.threads_enter( )
try:
self.update_display( )
finally:
gdk.threads_leave( )
self.update_display( )
######################## events from buttons
@@ -4159,7 +4143,7 @@ class ScriptInterface:
# start them
if self.back_driver is not None:
self.back_driver.start( )
while (not self.back_driver.quit_flag.isSet( ) and \
while (not self.back_driver.quit_flag.is_set( ) and \
(self.back_driver.core_pid is None or self.back_driver.core_pid <= 0)):
while gtk.events_pending():
gtk.main_iteration_do(False)
+27 -16
View File
@@ -5,6 +5,7 @@ import sys
import time
import logging
import ast
import contextlib
from damaris.experiments.Experiment import Quit
from damaris.experiments import Experiment
@@ -36,6 +37,19 @@ class _SandboxStdout:
return False
@contextlib.contextmanager
def _sandbox_stdout_redirect(log_callback):
"""Temporarily redirect process-global stdout/stderr to the sandbox log."""
old_stdout, old_stderr = sys.stdout, sys.stderr
sandbox = _SandboxStdout(log_callback)
try:
sys.stdout = sandbox
sys.stderr = sandbox
yield sandbox
finally:
sys.stdout, sys.stderr = old_stdout, old_stderr
class ExperimentHandling(threading.Thread):
"""
runs the experiment script in sandbox
@@ -52,17 +66,25 @@ class ExperimentHandling(threading.Thread):
self.data["__recentexperiment"]=-1
def synchronize(self, before=0, waitsteps=0.1):
while (self.data["__recentexperiment"]>self.data["__recentresult"]+before) and not self.quit_flag.isSet():
while (self.data["__recentexperiment"]>self.data["__recentresult"]+before) and not self.quit_flag.is_set():
self.quit_flag.wait(waitsteps)
if self.quit_flag.isSet():
if self.quit_flag.is_set():
raise StopExperiment
def sleep(self, seconds):
self.quit_flag.wait(seconds)
if self.quit_flag.isSet():
if self.quit_flag.is_set():
raise StopExperiment
def run(self):
# Redirect stdout/stderr so print() in sandboxed scripts goes to the GUI
if self._log_callback is not None:
with _sandbox_stdout_redirect(self._log_callback):
self._run_script()
else:
self._run_script()
def _run_script(self):
dataspace={}
exp_classes = __import__('damaris.experiments', dataspace, dataspace, ['Experiment'])
for name in dir(exp_classes):
@@ -77,13 +99,6 @@ class ExperimentHandling(threading.Thread):
self.location = None
exp_iterator=None
# Redirect stdout/stderr so print() in sandboxed scripts goes to the GUI
_orig_stdout = sys.stdout
_orig_stderr = sys.stderr
if self._log_callback is not None:
sys.stdout = _SandboxStdout(self._log_callback)
sys.stderr = _SandboxStdout(self._log_callback)
# check for time.sleep()
try:
tree = ast.parse(self.script)
@@ -131,7 +146,7 @@ class ExperimentHandling(threading.Thread):
traceback_file=None
return
while exp_iterator is not None and not self.quit_flag.isSet():
while exp_iterator is not None and not self.quit_flag.is_set():
# get next experiment from script
try:
job=next(exp_iterator)
@@ -171,7 +186,7 @@ class ExperimentHandling(threading.Thread):
# relax for a short time
if "__resultsinadvance" in self.data and self.data["__resultsinadvance"]+100<job.job_id:
self.quit_flag.wait(0.05)
if self.quit_flag.isSet():
if self.quit_flag.is_set():
dataspace=None
exp_iterator=None
break
@@ -181,7 +196,3 @@ class ExperimentHandling(threading.Thread):
dataspace=None
self.exp_iterator=None
self.writer=None
# Restore stdout/stderr
sys.stdout = _orig_stdout
sys.stderr = _orig_stderr
+10 -13
View File
@@ -7,7 +7,7 @@ import traceback
import logging
import ast
from damaris.data import Resultable
from damaris.gui.ExperimentHandling import StopExperiment, _SandboxStdout
from damaris.gui.ExperimentHandling import StopExperiment, _sandbox_stdout_redirect
logger = logging.getLogger("damaris.result_handling")
@@ -28,12 +28,13 @@ class ResultHandling(threading.Thread):
def run(self):
# Redirect stdout/stderr so print() in sandboxed scripts goes to the GUI
_orig_stdout = sys.stdout
_orig_stderr = sys.stderr
if self._log_callback is not None:
sys.stdout = _SandboxStdout(self._log_callback)
sys.stderr = _SandboxStdout(self._log_callback)
with _sandbox_stdout_redirect(self._log_callback):
self._run_script()
else:
self._run_script()
def _run_script(self):
# execute it
dataspace={}
data_classes = __import__('damaris.data', dataspace, dataspace, ['*'])
@@ -96,30 +97,26 @@ class ResultHandling(threading.Thread):
traceback_file=None
dataspace=None
# Restore stdout/stderr
sys.stdout = _orig_stdout
sys.stderr = _orig_stderr
def sleep(self, seconds):
self.quit_flag.wait(seconds)
if self.quit_flag.isSet():
if self.quit_flag.is_set():
raise StopExperiment
def __iter__(self):
if self.quit_flag.isSet():
if self.quit_flag.is_set():
self.results=None
return
for i in self.results:
if hasattr(self.results, "in_advance"):
self.data_space["__resultsinadvance"]=self.results.in_advance
if self.quit_flag.isSet():
if self.quit_flag.is_set():
self.results=None
return
if isinstance(i, Resultable.Resultable):
if self.data_space is not None:
self.data_space["__recentresult"]=i.job_id+0
yield i
if self.quit_flag.isSet():
if self.quit_flag.is_set():
self.results=None
return
+6 -6
View File
@@ -15,7 +15,7 @@ import base64
import numpy
try:
import xml.etree.cElementTree
import xml.etree.ElementTree
ELEMENT_TREE = True
except:
import xml.parsers.expat
@@ -109,7 +109,7 @@ class ResultReader:
self.result_job_number = None
# Job Date is set in __read_file()
self.__filetype = None
for elem in xml.etree.cElementTree.ElementTree(file=in_file).iter():
for elem in xml.etree.ElementTree.ElementTree(file=in_file).iter():
if elem.tag == 'result':
self.result_job_number = int(elem.get("job"))
pass
@@ -542,7 +542,7 @@ class BlockingResultReader(ResultReader):
block until result is available
"""
expected_filename=os.path.join(self.spool_dir,self.result_pattern%(self.no))
while (not self.quit_flag.isSet()) and (self.stop_no is None or self.stop_no>self.no):
while (not self.quit_flag.is_set()) and (self.stop_no is None or self.stop_no>self.no):
if not os.access(expected_filename,os.R_OK):
# stop polling, if required
if self.poll_time<0: break
@@ -558,11 +558,11 @@ class BlockingResultReader(ResultReader):
self.in_advance+=1
in_advance_filename=os.path.join(self.spool_dir,self.result_pattern%(self.in_advance+1))
if self.quit_flag.isSet(): break
if self.quit_flag.is_set(): break
r=self.get_result_object(expected_filename)
if self.quit_flag.isSet(): break
if self.quit_flag.is_set(): break
if self.quit_flag.isSet(): break
if self.quit_flag.is_set(): break
yield r
if self.clear_results:
+25 -32
View File
@@ -13,25 +13,6 @@ import datetime
from damaris.data import TemperatureResult
from damaris.tools.temperature import TemperatureController
# GTK imports are done lazily to avoid initialization issues
_HAS_GTK = None
_gdk = None
def _get_gdk():
"""Lazily import gdk to avoid GTK initialization issues."""
global _HAS_GTK, _gdk
if _HAS_GTK is None:
try:
import gi
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk as gdk
_gdk = gdk
_HAS_GTK = True
except (ImportError, ValueError):
_HAS_GTK = False
return _gdk if _HAS_GTK else None
class TemperatureMonitor(threading.Thread):
"""
@@ -72,6 +53,10 @@ class TemperatureMonitor(threading.Thread):
self.result = TemperatureResult()
self._running = False
self._lock = threading.RLock()
self._consecutive_failures = 0
self._max_consecutive_failures = 10
self._last_error = None
self._connection_ok = True
# Initialize the result with metadata
self.result.xlabel = "Time (s)"
@@ -124,21 +109,29 @@ class TemperatureMonitor(threading.Thread):
data_pool_result.job_id = self.result.job_id if hasattr(self.result, 'job_id') else None
data_pool_result.job_date = self.result.job_date if hasattr(self.result, 'job_date') else None
# Store in data_pool in GTK thread (if GTK available)
gdk = _get_gdk()
if gdk is not None:
gdk.threads_enter()
try:
self.data_pool[self.data_key] = data_pool_result
finally:
gdk.threads_leave()
else:
self.data_pool[self.data_key] = data_pool_result
# Store in data_pool (dict item assignment is atomic under the GIL)
self.data_pool[self.data_key] = data_pool_result
# Reset consecutive failure counter on successful read
self._consecutive_failures = 0
self._last_error = None
self._connection_ok = True
except Exception as e:
print(f"TemperatureMonitor error: {e}")
import traceback
traceback.print_exc()
self._consecutive_failures += 1
self._last_error = str(e)
if self._consecutive_failures <= 3:
print(f"TemperatureMonitor read error (attempt {self._consecutive_failures}): {e}")
elif self._consecutive_failures == 5:
print(f"TemperatureMonitor: repeated read errors, checking connection...")
if hasattr(self.controller, 'check_connection'):
self._connection_ok = self.controller.check_connection()
if not self._connection_ok:
print(f"TemperatureMonitor: WARNING - controller appears disconnected after {self._consecutive_failures} failures")
elif self._consecutive_failures >= self._max_consecutive_failures:
print(f"TemperatureMonitor: ERROR - {self._consecutive_failures} consecutive failures, controller may be disconnected")
else:
print(f"TemperatureMonitor error (failure #{self._consecutive_failures}): {e}")
# Sleep in small increments for responsive shutdown
sleep_remaining = self.interval
+24 -14
View File
@@ -1,5 +1,6 @@
import serial
import re
import time
import operator
from functools import reduce
@@ -57,21 +58,22 @@ class Eurotherm(TemperatureController):
def send_read_param(self, param):
self.s.write(EOT + self.device + param + ENQ)
def read_param(self, param):
self.s.flushInput()
self.send_read_param(param)
answer = self.s.read(self._expect_len)
m = reply_pattern.search(answer)
if m is None:
# Reading _expect_len bytes was not enough...
answer += self.s.read(200)
def read_param(self, param, retries=3, retry_delay=0.05):
for attempt in range(retries):
self.s.flushInput()
self.send_read_param(param)
answer = self.s.read(self._expect_len)
m = reply_pattern.search(answer)
if m is not None:
self._expect_len = len(answer)
return m.group(1)
else:
print("received:", repr(answer))
return None
if m is None:
answer += self.s.read(200)
m = reply_pattern.search(answer)
if m is not None:
self._expect_len = len(answer)
return m.group(1)
if attempt < retries - 1:
time.sleep(retry_delay)
print("received:", repr(answer))
return None
def write_param(self, mnemonic, data):
if len(mnemonic) > 2:
@@ -128,6 +130,14 @@ class Eurotherm(TemperatureController):
"""Set target temperature in Celsius."""
return self.write_param('SL', str(temperature))
def check_connection(self) -> bool:
"""Check if the Eurotherm device is connected and responsive."""
try:
result = self.read_param('PV')
return result is not None
except Exception:
return False
def close(self):
"""Close the serial connection."""
if hasattr(self, 's') and self.s is not None:
+20 -7
View File
@@ -1,5 +1,6 @@
import serial
import re
import time
import numpy as np
from .temperature import TemperatureController, register_controller
@@ -91,13 +92,17 @@ class Lakeshore(TemperatureController):
else:
return 0.0
def read_param(self, param):
def read_param(self, param, retries=3, retry_delay=0.05):
"""Read a parameter using simple ASCII commands."""
self.s.write(f"{param}?\n")
answer = self.s.readline().strip()
if answer and len(answer) > 1:
# Remove the first character (command echo) and return the value
return answer[1:]
for attempt in range(retries):
self.s.flushInput()
self.s.write(f"{param}?\n")
answer = self.s.readline().strip()
if answer and len(answer) > 1:
# Remove the first character (command echo) and return the value
return answer[1:]
if attempt < retries - 1:
time.sleep(retry_delay)
return None
def get_value(self, name):
@@ -134,9 +139,17 @@ class Lakeshore(TemperatureController):
def set_setpoint(self, temperature: float) -> bool:
"""Set target temperature in Celsius."""
self.s.write(f"SETP {temperature}\n")
self.s.write(f"SETP 1,{temperature}\n")
return True
def check_connection(self) -> bool:
"""Check if the Lakeshore device is connected and responsive."""
try:
result = self.read_param('KRDG')
return result is not None
except Exception:
return False
def close(self):
"""Close the serial connection."""
if hasattr(self, 's') and self.s is not None:
+10
View File
@@ -149,3 +149,13 @@ class TemperatureController(ABC):
if they have resources to clean up.
"""
pass
def check_connection(self) -> bool:
"""
Check if the controller is connected and responsive.
Returns:
bool: True if the controller is connected and responsive,
False otherwise. Subclasses should override this method.
"""
return False