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

+2
View File
@@ -14,6 +14,8 @@ debian/.debhelper
# Sphinx documentation
doc/_build/
doc/_autosummary/
doc/damaris/
# Debian build artifacts
debian/*.debhelper.log
+15
View File
@@ -26,6 +26,7 @@ The data module contains result types, data handling, and signal processing clas
damaris.data.ADC_Result
damaris.data.Accumulation
damaris.data.Config_Result
damaris.data.DamarisFFT
damaris.data.DataPool
damaris.data.Drawable
@@ -71,6 +72,20 @@ The GUI module contains the graphical interface, backend driver, and experiment/
damaris.gui.TemperatureMonitor
damaris.gui.logging_utils
A short usage example for the font helper of the GUI module (the non-deprecated
replacement for the removed ``Widget.modify_font()``):
.. code-block:: python
from damaris.gui.DamarisGUI import apply_font_css
apply_font_css(textview, "Monospace 10") # pango style font description
apply_font_css(label, "sans bold 16")
The font string is converted into a CSS rule (``font-family``/``font-size``/
``font-weight``) which is applied to the widget's style context with a
``Gtk.CssProvider``.
Tools Module
============
+10
View File
@@ -313,6 +313,10 @@ The change is *in-place*, which means if the timesignal is needed later on, a co
- **clip(start=**\ *None* **, stop=**\ *None* **)**
Only the data between start and stop is returned.
The start and stop parameters can be either in time or frequency domain.
Calling **clip()** without any argument is a placeholder that leaves the
object unchanged (useful in scripted processing chains). If only one bound
is given, the missing one defaults to the first resp. last data point and
reversed bounds (start > stop) are swapped automatically.
- **baseline(lastpart=**\ *0.1* **)**
This method should be applied first as it corrects the baseline of the signal, taking the last part of the signal for determining the correction.
@@ -374,6 +378,12 @@ To use this methods one has to apply consecutively the methods to an *timesignal
This applies baseline correction using the last 40% of the timesignal for determination of the signal. Then, an exponential window is applied. From this timesignal an FFT is calculated which is further clipped to view to the data between -1 kHz with 1 kHz width.
Operators and Copies of Data Objects
====================================
The arithmetic operators (``+``, ``-``, ``*``, ``/``, ``//``, ``**``, unary ``-``) of **ADC_Result** and **Accumulation** objects return *new* objects: the operands are never modified and the data arrays of the result are independent copies, so later in-place operations (``+=``, ``*=``, ...) on the result cannot corrupt the operands. The channel metadata is carried over to the result: an **ADC_Result** result keeps the same ``get_nChannels()`` and ``is_clipped`` flag as its operand, and an **Accumulation** result keeps (and merges) the ``job_ids`` provenance of both operands. Adding an unsupported type or negating an empty accumulation raises a ``ValueError`` instead of silently returning *None*. In the same spirit, ``Config_Result(config=..., desc=...)`` stores the given dictionaries in the constructor, and ``measurementresult + 0`` returns an independent deep copy of the result object.
Good Practices
==============
+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
+4 -3
View File
@@ -11,6 +11,7 @@ def test(i):
return e
def experiment():
for i in range(1400):
sleep(0.1)
yield test(i)
for i in range(100):
#sleep(0.1)
yield test(i)
synchronize()
+2
View File
@@ -9,6 +9,7 @@ def result():
except:
print("h5 already opened")
accu = Accumulation(error=False)
for num,ts in enumerate(results):
data["accu"] = accu
data["data/ts %i"%num] = ts+0
@@ -17,6 +18,7 @@ def result():
r[num] = ts.y[0][0]
data["test"] = r
accu+=ts
print(accu)
accu.write_to_hdf(h5, "/", "name", "title")
try:
h5.close()
+38 -11
View File
@@ -195,14 +195,14 @@ class TestADCResult(unittest.TestCase):
np.testing.assert_array_equal(adc.y[i]+10, y[i] + 10, strict=True)
adc += 10
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] + 10, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] + 10).astype("float32"), strict=True)
# test float addition
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i] + 10., y[i] + 10., strict=True)
np.testing.assert_array_equal(adc.y[i] + 10., (y[i] + 10 + 10.).astype("float32"), strict=True)
adc += 10.
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] + 10 + 10., strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] + 10 + 10.).astype("float32"), strict=True)
def test_operator_sub_scalar(self):
"""
@@ -215,14 +215,14 @@ class TestADCResult(unittest.TestCase):
np.testing.assert_array_equal(adc.y[i] - 10, y[i] - 10, strict=True)
adc -= 10
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] - 10, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] - 10).astype("float32"), strict=True)
# test float subtraction
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i] - 10., y[i] - 10., strict=True)
np.testing.assert_array_equal(adc.y[i] - 10., (y[i] - 10 - 10.).astype("float32"), strict=True)
adc -= 10.
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] - 10 - 10., strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] - 10 - 10.).astype("float32"), strict=True)
def test_operator_mul_scalar(self):
"""
@@ -236,14 +236,14 @@ class TestADCResult(unittest.TestCase):
np.testing.assert_array_equal(adc.y[i] * 10, y[i] * 10, strict=True)
adc *= 10
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] * 10, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] * 10).astype("float32"), strict=True)
# test float multiplication
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i] * 10.0, y[i] * 10.0, strict=True)
np.testing.assert_array_equal(adc.y[i] * 10.0, (y[i] * 10 * 10.0).astype("float32"), strict=True)
adc *= 10.0
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] * 10 * 10.0, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] * 10 * 10.0).astype("float32"), strict=True)
def test_operator_truediv_scalar(self):
"""
@@ -255,7 +255,7 @@ class TestADCResult(unittest.TestCase):
np.testing.assert_array_equal(adc.y[i]/10, y[i] / 10, strict=True)
adc /= 10
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] / 10, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] / 10).astype("float32"), strict=True)
def test_operator_floordiv_scalar(self):
"""
@@ -267,10 +267,37 @@ class TestADCResult(unittest.TestCase):
np.testing.assert_array_equal(adc.y[i]//10, y[i] // 10, strict=True)
adc //= 10
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(adc.y[i], y[i] // 10, strict=True)
np.testing.assert_array_equal(adc.y[i], (y[i] // 10).astype("float32"), strict=True)
self.assertRaises(ValueError, adc.__floordiv__, 1.0)
self.assertRaises(ValueError, adc.__rfloordiv__, 1.0)
def test_operators_keep_channel_metadata(self):
"""operator results keep nChannels/is_clipped identical to the operand"""
adc = self.create_adc_result()
adc.is_clipped = True
results = [adc + 1, adc - 1, adc * 2, adc ** 2, adc / 2., adc // 2,
10 + adc, 10 - adc, 2 * adc, 100. / adc, 100 // adc, -adc]
for r in results:
self.assertEqual(r.get_nChannels(), adc.get_nChannels())
self.assertEqual(r.is_clipped, adc.is_clipped)
def test_hdf_roundtrip_keeps_nchannels(self):
"""read_from_hdf must restore the number of channels"""
import tempfile
import tables
from damaris.data.ADC_Result import read_from_hdf
adc = self.create_adc_result()
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "adc.h5")
with tables.open_file(path, "w") as h5:
adc.write_to_hdf(h5, h5.root, "adc", "test")
with tables.open_file(path, "r") as h5:
adc2 = read_from_hdf(h5.root.adc)
self.assertIsNotNone(adc2)
self.assertTrue(adc2.contains_data())
self.assertEqual(adc2.get_nChannels(), adc.get_nChannels())
def test_operator_overload_adc_result_exception(self):
"""
Test the ValueError raised when ADC_Result is used as an operand in an arithmetic operation
+71 -1
View File
@@ -1,5 +1,6 @@
import os
import subprocess
import threading
import unittest
from datetime import datetime
@@ -258,7 +259,7 @@ class TestAccumulation(unittest.TestCase):
np.testing.assert_array_equal(accu.y[i], y[i] - 10)
# test float subtraction
adc -= 10.
accu -= 10.
for i in range(adc.get_nChannels()):
np.testing.assert_array_equal(accu.y[i], y[i] - 10 - 10.)
@@ -312,5 +313,74 @@ class TestAccumulation(unittest.TestCase):
self.assertRaises(ValueError, accu.__floordiv__, 1.0)
self.assertRaises(ValueError, accu.__ifloordiv__, 1.0)
def test_add_invalid_operand_raises(self):
"""accu + bogus must raise ValueError instead of returning None."""
adc = self.create_adc_result()
accu = Accumulation()
accu += adc
self.assertRaises(ValueError, accu.__add__, "foo")
self.assertRaises(ValueError, accu.__iadd__, "foo")
def test_neg_empty_raises(self):
"""-empty accumulation must raise ValueError."""
self.assertRaises(ValueError, Accumulation().__neg__)
def test_add_adc_result_does_not_modify_job_ids(self):
"""accu + adc must not insert the adc job id into accu.job_ids."""
accu = Accumulation()
accu += self.create_adc_result()
n_before = len(accu.job_ids)
adc2 = self.create_adc_result()
adc2.job_id = 12
r = accu + adc2
self.assertIn(12, r.job_ids)
self.assertNotIn(12, accu.job_ids)
self.assertEqual(len(accu.job_ids), n_before)
def test_empty_plus_accumulation_copies_data(self):
"""empty_accu + accu must copy arrays and metadata, not alias them."""
adc = self.create_adc_result()
accu = Accumulation()
accu += adc
accu.is_clipped = True
r = Accumulation() + accu
self.assertIsNot(r.y[0], accu.y[0])
self.assertIsNot(r.job_ids, accu.job_ids)
self.assertTrue(r.is_clipped)
# in-place operation on the result must leave the operand unchanged
r *= 2
np.testing.assert_array_equal(accu.y[0], np.array([10, 20, 30], dtype="float32"))
def test_accumulation_plus_accumulation_keeps_both_job_ids(self):
"""accu1 + accu2 must keep the provenance of both operands."""
adc1 = self.create_adc_result()
adc2 = self.create_adc_result()
adc2.job_id = 12
accu1 = Accumulation()
accu1 += adc1
accu2 = Accumulation()
accu2 += adc2
r = accu1 + accu2
self.assertEqual(set(r.job_ids.keys()), {11, 12})
self.assertEqual(set(accu1.job_ids.keys()), {11})
self.assertEqual(set(accu2.job_ids.keys()), {12})
def test_empty_error_plus_non_error_raises(self):
"""error accumulation + non-error accumulation must raise ValueError."""
accu = Accumulation()
accu += self.create_adc_result()
empty_err = Accumulation(error=True)
self.assertRaises(ValueError, empty_err.__add__, accu)
free = []
def check_lock():
if empty_err.lock.acquire(timeout=1):
free.append(True)
empty_err.lock.release()
t = threading.Thread(target=check_lock)
t.start()
t.join(3)
self.assertTrue(free, "lock leaked after ValueError in __add__")
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import unittest
from damaris.data.Config_Result import Config_Result
class TestConfigResult(unittest.TestCase):
def test_constructor_with_no_arguments(self):
"""Test initializer with no arguments."""
cr = Config_Result()
self.assertEqual(cr.get_config_dictionary(), {})
self.assertEqual(cr.description, {})
def test_constructor_with_arguments(self):
"""Test initializer with config and description arguments."""
config = {"a": 1}
desc = {"b": 2}
cr = Config_Result(config=config, desc=desc)
self.assertEqual(cr.get_config_dictionary(), config)
self.assertEqual(cr.description, desc)
def test_get_set_config(self):
cr = Config_Result()
cr.set_config("key", "value")
self.assertEqual(cr.get_config("key"), "value")
self.assertIsNone(cr.get_config("missing"))
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import unittest
import numpy as np
from damaris.data.DamarisFFT import DamarisFFT
class TestDamarisFFT(unittest.TestCase):
@staticmethod
def make_fft():
fft = DamarisFFT()
fft.sampling_rate = 100.0
fft.x = np.arange(10, dtype="float32") / 100.0
fft.y = [np.arange(10, dtype="float32")]
fft.xlabel = "Time / s"
return fft
def test_clip_without_arguments_is_placeholder(self):
"""clip() without arguments returns self unchanged (placeholder use)."""
fft = self.make_fft()
self.assertIs(fft.clip(), fft)
self.assertEqual(len(fft.x), 10)
np.testing.assert_array_equal(fft.y[0], np.arange(10, dtype="float32"))
def test_clip_swaps_reversed_bounds(self):
fft = self.make_fft()
fft.clip(start=0.05, stop=0.01)
expected_x = (np.arange(10, dtype="float32") / 100.0)[1:5]
np.testing.assert_array_equal(fft.x, expected_x)
np.testing.assert_array_equal(fft.y[0], np.arange(1, 5, dtype="float32"))
def test_clip_single_bound(self):
fft = self.make_fft()
fft.clip(start=0.02)
self.assertEqual(len(fft.x), 7)
self.assertEqual(len(fft.y[0]), 7)
def test_clip_too_close_raises(self):
fft = self.make_fft()
self.assertRaises(ValueError, fft.clip, 0.021, 0.029)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
import sys
import threading
import unittest
from damaris.gui.ExperimentHandling import ExperimentHandling
from damaris.gui.ResultHandling import ResultHandling
class _FakeWriter:
def __init__(self):
self.sent = []
def send_next(self, job, quit=False):
self.sent.append(job)
class _FakeResultIterator:
def __init__(self):
self.quit_flag = threading.Event()
def __iter__(self):
return iter([])
class TestSandboxStdoutRestore(unittest.TestCase):
"""
sys.stdout/sys.stderr are process-global: the sandbox redirect in
run() must be undone even when the script raises or returns early.
"""
def test_experiment_stdout_restored_after_script_exception(self):
orig_stdout = sys.stdout
orig_stderr = sys.stderr
handled = ExperimentHandling("raise ValueError('boom')", None, None,
log_callback=lambda line: None)
handled.run()
self.assertIs(sys.stdout, orig_stdout)
self.assertIs(sys.stderr, orig_stderr)
self.assertIsNotNone(handled.raised_exception)
def test_experiment_stdout_restored_on_success(self):
orig_stdout = sys.stdout
writer = _FakeWriter()
handled = ExperimentHandling("print('hello')\nexperiment = lambda: iter([])",
writer, None, log_callback=lambda line: None)
handled.run()
self.assertIs(sys.stdout, orig_stdout)
self.assertIsNone(handled.raised_exception)
def test_result_stdout_restored_after_script_exception(self):
orig_stdout = sys.stdout
orig_stderr = sys.stderr
handling = ResultHandling("raise ValueError('boom')", _FakeResultIterator(),
None, log_callback=lambda line: None)
handling.run()
self.assertIs(sys.stdout, orig_stdout)
self.assertIs(sys.stderr, orig_stderr)
self.assertIsNotNone(handling.raised_exception)
def test_result_stdout_restored_without_result_function(self):
orig_stdout = sys.stdout
handling = ResultHandling("x = 1", _FakeResultIterator(), None,
log_callback=lambda line: None)
handling.run()
self.assertIs(sys.stdout, orig_stdout)
self.assertIsNone(handling.raised_exception)
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
import unittest
import numpy as np
from damaris.data.MeasurementResult import MeasurementResult
class TestMeasurementResult(unittest.TestCase):
def test_add_zero_preserves_data(self):
"""mr + 0 must return a MeasurementResult with identical data."""
mr = MeasurementResult("test_quantity")
for i in range(3):
mr[i] = float(i * i)
mr2 = mr + 0
self.assertIsInstance(mr2, MeasurementResult)
self.assertEqual(mr2.quantity_name, "test_quantity")
np.testing.assert_array_equal(mr2.get_xdata(), mr.get_xdata())
np.testing.assert_array_equal(mr2.get_ydata(), mr.get_ydata())
def test_add_zero_returns_independent_copy(self):
"""Changing the copy must not change the original."""
mr = MeasurementResult("q")
mr[1.0] = 5.0
mr2 = mr + 0
self.assertIsNot(mr2[1.0], mr[1.0])
mr2[1.0] = mr2[1.0] + 100.0
self.assertEqual(mr[1.0].mean(), 5.0)
def test_add_nonzero_raises(self):
mr = MeasurementResult("q")
self.assertRaises(Exception, mr.__add__, 1)
if __name__ == "__main__":
unittest.main()