From f9d8de7697ff56d5d72807504cc0012c23f5d743 Mon Sep 17 00:00:00 2001 From: Markus Rosenstihl Date: Sat, 17 Oct 2026 19:17:43 +0200 Subject: [PATCH] fix return-value/deprecated API bugs, accumulation lock leaks, code cleanup - 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/ --- .gitignore | 2 + doc/api_reference.rst | 15 +++ doc/manual.rst | 10 ++ src/damaris/data/ADC_Result.py | 22 +++- src/damaris/data/Accumulation.py | 62 ++++++---- src/damaris/data/Config_Result.py | 4 + src/damaris/data/DamarisFFT.py | 8 +- src/damaris/data/MeasurementResult.py | 5 +- src/damaris/data/Signalpath.py | 6 +- src/damaris/gui/BackendDriver.py | 6 +- src/damaris/gui/DamarisGUI.py | 168 ++++++++++++-------------- src/damaris/gui/ExperimentHandling.py | 43 ++++--- src/damaris/gui/ResultHandling.py | 23 ++-- src/damaris/gui/ResultReader.py | 12 +- src/damaris/gui/TemperatureMonitor.py | 57 ++++----- src/damaris/tools/eurotherm.py | 38 +++--- src/damaris/tools/lakeshore.py | 27 +++-- src/damaris/tools/temperature.py | 10 ++ tests/exp_test.py | 7 +- tests/res_test.py | 2 + tests/test_ADC_Result.py | 49 ++++++-- tests/test_Accumulation.py | 72 ++++++++++- tests/test_Config_Result.py | 29 +++++ tests/test_DamarisFFT.py | 44 +++++++ tests/test_ExperimentHandling.py | 70 +++++++++++ tests/test_MeasurementResult.py | 35 ++++++ 26 files changed, 590 insertions(+), 236 deletions(-) create mode 100644 tests/test_Config_Result.py create mode 100644 tests/test_DamarisFFT.py create mode 100644 tests/test_ExperimentHandling.py create mode 100644 tests/test_MeasurementResult.py diff --git a/.gitignore b/.gitignore index 9be833f..70043b9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ debian/.debhelper # Sphinx documentation doc/_build/ +doc/_autosummary/ +doc/damaris/ # Debian build artifacts debian/*.debhelper.log diff --git a/doc/api_reference.rst b/doc/api_reference.rst index fae80bb..5d97911 100644 --- a/doc/api_reference.rst +++ b/doc/api_reference.rst @@ -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 ============ diff --git a/doc/manual.rst b/doc/manual.rst index 73ce857..c3dfdbb 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -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 ============== diff --git a/src/damaris/data/ADC_Result.py b/src/damaris/data/ADC_Result.py index a709729..d8b3a6e 100644 --- a/src/damaris/data/ADC_Result.py +++ b/src/damaris/data/ADC_Result.py @@ -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 diff --git a/src/damaris/data/Accumulation.py b/src/damaris/data/Accumulation.py index f9ee5f0..4519a09 100644 --- a/src/damaris/data/Accumulation.py +++ b/src/damaris/data/Accumulation.py @@ -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 diff --git a/src/damaris/data/Config_Result.py b/src/damaris/data/Config_Result.py index 627058a..a6c858b 100644 --- a/src/damaris/data/Config_Result.py +++ b/src/damaris/data/Config_Result.py @@ -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 diff --git a/src/damaris/data/DamarisFFT.py b/src/damaris/data/DamarisFFT.py index a4b43e2..71b0baa 100644 --- a/src/damaris/data/DamarisFFT.py +++ b/src/damaris/data/DamarisFFT.py @@ -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) diff --git a/src/damaris/data/MeasurementResult.py b/src/damaris/data/MeasurementResult.py index d81c1c9..97d34eb 100644 --- a/src/damaris/data/MeasurementResult.py +++ b/src/damaris/data/MeasurementResult.py @@ -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") diff --git a/src/damaris/data/Signalpath.py b/src/damaris/data/Signalpath.py index 276a534..abd4da0 100644 --- a/src/damaris/data/Signalpath.py +++ b/src/damaris/data/Signalpath.py @@ -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) diff --git a/src/damaris/gui/BackendDriver.py b/src/damaris/gui/BackendDriver.py index a5f899d..57bf19a 100644 --- a/src/damaris/gui/BackendDriver.py +++ b/src/damaris/gui/BackendDriver.py @@ -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) diff --git a/src/damaris/gui/DamarisGUI.py b/src/damaris/gui/DamarisGUI.py index c66079a..101eda4 100644 --- a/src/damaris/gui/DamarisGUI.py +++ b/src/damaris/gui/DamarisGUI.py @@ -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) diff --git a/src/damaris/gui/ExperimentHandling.py b/src/damaris/gui/ExperimentHandling.py index 6823719..72a9ff8 100644 --- a/src/damaris/gui/ExperimentHandling.py +++ b/src/damaris/gui/ExperimentHandling.py @@ -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"]+100self.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: diff --git a/src/damaris/gui/TemperatureMonitor.py b/src/damaris/gui/TemperatureMonitor.py index d945b70..af28e57 100644 --- a/src/damaris/gui/TemperatureMonitor.py +++ b/src/damaris/gui/TemperatureMonitor.py @@ -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 diff --git a/src/damaris/tools/eurotherm.py b/src/damaris/tools/eurotherm.py index d231779..b49fd6a 100644 --- a/src/damaris/tools/eurotherm.py +++ b/src/damaris/tools/eurotherm.py @@ -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: diff --git a/src/damaris/tools/lakeshore.py b/src/damaris/tools/lakeshore.py index 561947d..17b43d4 100644 --- a/src/damaris/tools/lakeshore.py +++ b/src/damaris/tools/lakeshore.py @@ -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: diff --git a/src/damaris/tools/temperature.py b/src/damaris/tools/temperature.py index a50c01d..eec67ac 100644 --- a/src/damaris/tools/temperature.py +++ b/src/damaris/tools/temperature.py @@ -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 diff --git a/tests/exp_test.py b/tests/exp_test.py index 475079d..6d472dc 100644 --- a/tests/exp_test.py +++ b/tests/exp_test.py @@ -11,6 +11,7 @@ def test(i): return e def experiment(): - for i in range(1400): - sleep(0.1) - yield test(i) \ No newline at end of file + for i in range(100): + #sleep(0.1) + yield test(i) + synchronize() \ No newline at end of file diff --git a/tests/res_test.py b/tests/res_test.py index 205074a..5ea9bb1 100644 --- a/tests/res_test.py +++ b/tests/res_test.py @@ -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() diff --git a/tests/test_ADC_Result.py b/tests/test_ADC_Result.py index ead41e3..c6044bb 100644 --- a/tests/test_ADC_Result.py +++ b/tests/test_ADC_Result.py @@ -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 diff --git a/tests/test_Accumulation.py b/tests/test_Accumulation.py index e0b3330..c2de3ea 100644 --- a/tests/test_Accumulation.py +++ b/tests/test_Accumulation.py @@ -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() diff --git a/tests/test_Config_Result.py b/tests/test_Config_Result.py new file mode 100644 index 0000000..9c5d612 --- /dev/null +++ b/tests/test_Config_Result.py @@ -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() diff --git a/tests/test_DamarisFFT.py b/tests/test_DamarisFFT.py new file mode 100644 index 0000000..429d12a --- /dev/null +++ b/tests/test_DamarisFFT.py @@ -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() diff --git a/tests/test_ExperimentHandling.py b/tests/test_ExperimentHandling.py new file mode 100644 index 0000000..b5f6afe --- /dev/null +++ b/tests/test_ExperimentHandling.py @@ -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() diff --git a/tests/test_MeasurementResult.py b/tests/test_MeasurementResult.py new file mode 100644 index 0000000..772bd84 --- /dev/null +++ b/tests/test_MeasurementResult.py @@ -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()