diff --git a/.gitea/workflows/debian-build.yml b/.gitea/workflows/debian-build.yml index 39d8cc7..65bf425 100644 --- a/.gitea/workflows/debian-build.yml +++ b/.gitea/workflows/debian-build.yml @@ -10,10 +10,8 @@ jobs: build: strategy: matrix: - target: [debian11, debian12, debian13] + target: [debian12, debian13] include: - - target: debian11 - codename: bullseye - target: debian12 codename: bookworm - target: debian13 diff --git a/.gitignore b/.gitignore index d887a0c..899db3d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ __pycache__ debian/files .pybuild .junie +debian/debhelper-build-stamp +debian/.debhelper diff --git a/README b/README index b802851..316abd7 100644 --- a/README +++ b/README @@ -15,3 +15,12 @@ Alternatively you can install withoout the system-site-packages: pip install -e . This will download newer packages and install them in the venv. + + +Using uv +======== +Dependicies needed: + sudo apt install libgirepository1.0-dev pkg-config + +Then you can start DAMARIS3: + uv run DAMARIS3 diff --git a/pyproject-bookworm.toml b/pyproject-bookworm.toml new file mode 100644 index 0000000..1a57d51 --- /dev/null +++ b/pyproject-bookworm.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "python3-damaris" +version = "0.22" +requires-python = ">= 3.11" +description = "Python 3 frontend for DAMARIS (DArmstadt MAgnetic Resonance Instrument Software)" +license = {text = "GPL-2.0"} +keywords = ["NMR", "data-processing"] +readme = {file = "README", content-type = "text/plain"} +dependencies = [ + "scipy>=1.7", + "numpy>=1.21,<2.0", + "tables>=3.7", + "matplotlib>=3.5,<3.11", + "pyxdg", + # PyGObject must come from system packages: + # apt-get install python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-3.0 +] +authors = [ + {name = "Achim Gädke"}, + {name = "Christopher Schmitt"}, + {name = "Christian Tacke"}, + {name = "Joachim Beerwerth"}, + {name = "Markus Rosenstihl", email = "markus.rosenstihl@pkm.tu-darmstadt.de"} +] + +[project.scripts] +DAMARIS3 = "damaris.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"damaris.gui" = ["DAMARIS3.png", "DAMARIS3.ico", "python.xml", "damaris3.glade"] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 479d2a6..7cd0331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ build-backend = "setuptools.build_meta" [project] -requires-python = ">= 3.9" +requires-python = ">= 3.11" name = "python3-damaris" dynamic = ["version"] description = "Python version 3 frontend for DAMARIS (DArmstadt MAgnetic Resonance Instrument Software)" @@ -14,12 +14,13 @@ license = {text = "GPL-2.0"} keywords = ["NMR", "data-processing"] readme = {file = "README", content-type = "text/plain"} dependencies = [ - "scipy >= 0.14.0", - "numpy >= 1.8.2, < 2.0", + "scipy>=0.14.0", + "numpy>=1.8.2,<2.0", "tables >= 3.1.1", "matplotlib >= 1.4.1, <= 3.6", "pyxdg", - "PyGObject >= 3.14.0, <= 3.51.0" + "PyGObject >= 3.14.0, <= 3.51.0", + "pyserial>=3.5", ] authors = [ {name = "Achim Gädke"}, diff --git a/src/damaris/data/ADC_Result.py b/src/damaris/data/ADC_Result.py index d7fe4a3..97bbd50 100644 --- a/src/damaris/data/ADC_Result.py +++ b/src/damaris/data/ADC_Result.py @@ -9,6 +9,7 @@ import numpy import sys import datetime import tables +from scipy.signal import filtfilt, remez ############################################################################# # # # Name: Class ADC_Result # @@ -93,8 +94,6 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): print("Warning ADC-Result: Tried to run \"create_data_space()\" more than once.") return - raise ValueError("ValueError: You cant create an ADC-Result with less than 1 sample!") - for i in range(channels): self.y.append(numpy.zeros((samples,), dtype="int16")) @@ -147,6 +146,97 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): return self.is_clipped + def lowpass(self, cutoff, numtaps=None): + """ + Apply a zero-phase lowpass FIR filter to all channels. + + Uses scipy.signal.filtfilt (forward-backward filtering) with a Parks-McClellan + FIR filter (remez). The forward-backward pass guarantees zero phase distortion + and doubles the effective filter order. + + The stopband is automatically set to 0.1 * Nyquist beyond the cutoff. If + numtaps is not provided, it is estimated from the transition width to yield + approximately 40-50 dB stopband attenuation (typically ~40 taps). + + Parameters + ---------- + cutoff : float + Passband edge frequency in Hz. Frequencies above this value are attenuated. + Must be a positive number below the Nyquist frequency. + numtaps : int, optional + Number of FIR taps. If omitted, computed automatically from the transition + width. + + Returns + ------- + ADC_Result + A new ADC_Result with the filtered data (float64 per channel). + The original object is not modified. + + Raises + ------ + RuntimeError + If the result contains no data. + ValueError + If cutoff is invalid for the current sampling rate. + + Examples + -------- + >>> # Lowpass at 1 MHz (taps auto-computed, ~40) + ... filtered = adc.lowpass(cutoff=1e6) + >>> # Lowpass at 500 kHz with fixed 63 taps + ... filtered = adc.lowpass(cutoff=500e3, numtaps=63) + """ + if not self.contains_data(): + raise RuntimeError("lowpass: no data present") + + nyquist = self.sampling_rate / 2.0 + cutoff = float(cutoff) + + if cutoff <= 0 or cutoff >= nyquist: + raise ValueError( + f"cutoff {cutoff} Hz is outside valid range (0, {nyquist})" + ) + + # Transition width: 0.1 * Nyquist + trans_width = 0.1 * nyquist + stopband = min(cutoff + trans_width, nyquist * 0.99) + + # Auto-estimate taps if not given + if numtaps is None: + numtaps = max(21, int(round(8.0 / (stopband - cutoff) * nyquist))) + if numtaps % 2 == 0: + numtaps += 1 + + coeffs = remez(numtaps, [0, cutoff, stopband, nyquist], [1, 0], fs=self.sampling_rate) + + self.lock.acquire() + try: + tmp_y = [] + for i in range(self.get_number_of_channels()): + data = numpy.asarray(self.y[i], dtype='float64') + filtered = filtfilt( + coeffs, [1.0], data, + padtype='odd', + padlen=(numtaps - 1) * 3 + ) + tmp_y.append(filtered) + + 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, + ) + finally: + self.lock.release() + + return r + + def add_sample_space(self, samples): "Adds space for n samples, where n can also be negative (deletes space). New space is filled up with \"0\"" @@ -220,26 +310,25 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): destination can be a file or a filename suitable for further processing """ - # write sorted - the_destination=destination - if type(destination) in (str,): - the_destination=open(destination, "w") + if isinstance(destination, str): + with open(destination, "w") as f: + self._write_to_csv_dest(f, delimiter) + else: + self._write_to_csv_dest(destination, delimiter) - the_destination.write("# adc_result\n") - the_destination.write("# t y0 y1 ...\n") + def _write_to_csv_dest(self, dest, delimiter): + dest.write("# adc_result\n") + dest.write("# t y0 y1 ...\n") self.lock.acquire() try: xdata=self.get_xdata() ch_no=self.get_number_of_channels() ydata=list(map(self.get_ydata, range(ch_no))) - #yerr=map(self.get_yerr, xrange(ch_no)) for i in range(len(xdata)): - the_destination.write("%e"%xdata[i]) + dest.write("%e"%xdata[i]) for j in range(ch_no): - the_destination.write("%s%e"%(delimiter, ydata[j][i])) - the_destination.write("\n") - the_destination=None - xdata=ydata=None + dest.write("%s%e"%(delimiter, ydata[j][i])) + dest.write("\n") finally: self.lock.release() @@ -249,28 +338,28 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath): for further processing with the NMRnotebook software; destination can be a file or a filename """ - # write sorted - the_destination=destination - if type(destination) in (str,): - the_destination=open(destination, "w") + if isinstance(destination, str): + with open(destination, "w") as f: + self._write_to_simpson_dest(f, delimiter) + else: + self._write_to_simpson_dest(destination, delimiter) + def _write_to_simpson_dest(self, dest, delimiter): self.lock.acquire() try: xdata=self.get_xdata() - the_destination.write("SIMP\n") - the_destination.write("%s%i%s"%("NP=", len(xdata), "\n")) - the_destination.write("%s%i%s"%("SW=", self.get_sampling_rate(), "\n")) - the_destination.write("TYPE=FID\n") - the_destination.write("DATA\n") + dest.write("SIMP\n") + dest.write("%s%i%s"%("NP=", len(xdata), "\n")) + dest.write("%s%i%s"%("SW=", self.get_sampling_rate(), "\n")) + dest.write("TYPE=FID\n") + dest.write("DATA\n") ch_no=self.get_number_of_channels() ydata=list(map(self.get_ydata, range(ch_no))) for i in range(len(xdata)): for j in range(ch_no): - the_destination.write("%g%s"%(ydata[j][i], delimiter)) - the_destination.write("\n") - the_destination.write("END\n") - the_destination=None - xdata=ydata=None + dest.write("%g%s"%(ydata[j][i], delimiter)) + dest.write("\n") + dest.write("END\n") finally: self.lock.release() diff --git a/src/damaris/data/Accumulation.py b/src/damaris/data/Accumulation.py index 53ff562..15a7787 100644 --- a/src/damaris/data/Accumulation.py +++ b/src/damaris/data/Accumulation.py @@ -248,38 +248,41 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): writes the data to a file. destination can be a filehandle or a filename, default sys.stdout """ - the_destination=destination if isinstance(destination, str): - the_destination=open(destination, "w") + with open(destination, "w") as f: + self._write_to_csv_dest(f, delimiter) + else: + self._write_to_csv_dest(destination, delimiter) - the_destination.write("# accumulation %d\n"%self.n) + def _write_to_csv_dest(self, dest, delimiter): + dest.write("# accumulation %d\n"%self.n) self.lock.acquire() try: if self.common_descriptions is not None: for (key,value) in self.common_descriptions.items(): - the_destination.write("# %s : %s\n"%(key, str(value))) - the_destination.write("# t") + dest.write("# %s : %s\n"%(key, str(value))) + dest.write("# t") ch_no=self.get_number_of_channels() if self.use_error: for i in range(ch_no): - the_destination.write(" ch%d_mean ch%d_err"%(i,i)) + dest.write(" ch%d_mean ch%d_err"%(i,i)) else: for i in range(ch_no): - the_destination.write(" ch%d_mean"%i) - the_destination.write("\n") + dest.write(" ch%d_mean"%i) + dest.write("\n") xdata=self.get_xdata() ydata=list(map(self.get_ydata, range(ch_no))) yerr=None if self.use_error: yerr=list(map(self.get_yerr, range(ch_no))) for i in range(len(xdata)): - the_destination.write("%e"%xdata[i]) + dest.write("%e"%xdata[i]) for j in range(ch_no): if self.use_error: - the_destination.write("%s%e%s%e"%(delimiter, ydata[j][i], delimiter, yerr[j][i])) + dest.write("%s%e%s%e"%(delimiter, ydata[j][i], delimiter, yerr[j][i])) else: - the_destination.write("%s%e"%(delimiter,ydata[j][i])) - the_destination.write("\n") + dest.write("%s%e"%(delimiter,ydata[j][i])) + dest.write("\n") finally: self.lock.release() @@ -290,27 +293,29 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath): for further processing with the NMRnotebook software; destination can be a file or a filename """ - the_destination=destination if isinstance(destination, str): - the_destination=open(destination, "w") + with open(destination, "w") as f: + self._write_to_simpson_dest(f, delimiter, frequency) + else: + self._write_to_simpson_dest(destination, delimiter, frequency) + def _write_to_simpson_dest(self, dest, delimiter, frequency): self.lock.acquire() try: xdata=self.get_xdata() - the_destination.write("SIMP\n") - the_destination.write("%s%i%s"%("NP=", len(xdata), "\n")) - the_destination.write("%s%i%s"%("SW=", self.get_sampling_rate(), "\n")) - the_destination.write("%s%i%s"%("REF=", frequency, "\n")) - the_destination.write("TYPE=FID\n") - the_destination.write("DATA\n") + dest.write("SIMP\n") + dest.write("%s%i%s"%("NP=", len(xdata), "\n")) + dest.write("%s%i%s"%("SW=", self.get_sampling_rate(), "\n")) + dest.write("%s%i%s"%("REF=", frequency, "\n")) + dest.write("TYPE=FID\n") + dest.write("DATA\n") ch_no=self.get_number_of_channels() ydata=list(map(self.get_ydata, range(ch_no))) for i in range(len(xdata)): for j in range(ch_no): - the_destination.write("%g%s"%(ydata[j][i], delimiter)) - the_destination.write("\n") - the_destination.write("END\n") - the_destination.close() + dest.write("%g%s"%(ydata[j][i], delimiter)) + dest.write("\n") + dest.write("END\n") finally: self.lock.release() diff --git a/src/damaris/data/DataPool.py b/src/damaris/data/DataPool.py index 7a654ca..297b38c 100644 --- a/src/damaris/data/DataPool.py +++ b/src/damaris/data/DataPool.py @@ -365,7 +365,13 @@ class DataPool(collections.abc.MutableMapping): obj = read_from_hdf(node) if obj is not None: self[full_path] = obj - + + elif damaris_type == "TemperatureResult": + from damaris.data.Temperature import read_from_hdf + obj = read_from_hdf(node) + if obj is not None: + self[full_path] = obj + # Skip further processing of this group since we handled it continue diff --git a/src/damaris/data/MeasurementResult.py b/src/damaris/data/MeasurementResult.py index c96c731..d81c1c9 100644 --- a/src/damaris/data/MeasurementResult.py +++ b/src/damaris/data/MeasurementResult.py @@ -1,6 +1,5 @@ import threading import math -import types import sys import tables import numpy @@ -45,7 +44,7 @@ class AccumulatedValue: def __add__(self,y): new_one=AccumulatedValue() - if (type(y) is types.InstanceType and isinstance(y, AccumulatedValue)): + if isinstance(y, AccumulatedValue): new_one.y=self.y+y.y new_one.y2=self.y2+y.y2 new_one.n=self.n+y.n @@ -56,7 +55,7 @@ class AccumulatedValue: return new_one def __iadd__(self,y): - if (type(y) is types.InstanceType and isinstance(y, AccumulatedValue)): + if isinstance(y, AccumulatedValue): self.y+=y.y self.y2+=y.y2 self.n+=y.n @@ -203,28 +202,27 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict): destination can be a file or a filename suitable for further processing """ - the_destination=destination - file_opened = False - if type(destination) in (str,): - the_destination=open(destination, "w") - file_opened = True + if isinstance(destination, str): + with open(destination, "w") as f: + self._write_to_csv_dest(f, delimiter) + else: + self._write_to_csv_dest(destination, delimiter) - the_destination.write("# quantity:"+str(self.quantity_name)+"\n") - the_destination.write("# x y ysigma n\n") + def _write_to_csv_dest(self, dest, delimiter): + dest.write("# quantity:"+str(self.quantity_name)+"\n") + dest.write("# x y ysigma n\n") for x in self.get_xdata(): - y=self.data[x] - if type(y) in [float, int, int]: - the_destination.write("%e%s%e%s0%s1\n"%(x, delimiter, y, delimiter, delimiter)) + y = self.data[x] + if isinstance(y, (float, int)): + dest.write("%e%s%e%s0%s1\n" % (x, delimiter, y, delimiter, delimiter)) else: - the_destination.write("%e%s%e%s%e%s%d\n"%(x, - delimiter, - y.mean(), - delimiter, - y.mean_error(), - delimiter, - y.n)) - if file_opened: - the_destination.close() + dest.write("%e%s%e%s%e%s%d\n" % (x, + delimiter, + y.mean(), + delimiter, + y.mean_error(), + delimiter, + y.n)) def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): diff --git a/src/damaris/data/Temperature.py b/src/damaris/data/Temperature.py index 400d018..85e174a 100644 --- a/src/damaris/data/Temperature.py +++ b/src/damaris/data/Temperature.py @@ -2,6 +2,9 @@ from .Resultable import Resultable from .Drawable import Drawable +import numpy +import threading +import tables ############################################################################# # # @@ -15,17 +18,224 @@ from .Drawable import Drawable class TemperatureResult(Resultable, Drawable): """ Specialised class of Resultable and Drawable - Contains recorded temperature data + Contains recorded temperature data. + + Attributes: + x: List of timestamps in seconds. + y: List of temperature readings in Celsius. + setpoint: Optional list of setpoint temperatures in Celsius. + xlabel: Label for x-axis (default: "Time (s)"). + ylabel: Label for y-axis (default: "Temperature (C)"). """ - def __init__(self, x = None, y = None, desc = None, job_id = None, job_date = None): + def __init__(self, x = None, y = None, setpoint = None, desc = None, job_id = None, job_date = None): Resultable.__init__(self) Drawable.__init__(self) + + # Set default labels + self.xlabel = "Time (s)" + self.ylabel = "Temperature (C)" + + # Initialize data lists + self.x = [] if x is None else x + self.y = [] if y is None else y + self.setpoint = [] if setpoint is None else setpoint + + # Set metadata if provided + if desc is not None: + self.description = desc + if job_id is not None: + self.job_id = job_id + if job_date is not None: + self.job_date = job_date + + # Listener management for GUI updates + self.__listeners = [] + self.__listener_lock = threading.Lock() + + def get_number_of_channels(self): + """Returns 1 for temperature data.""" + return 1 + + def register_listener(self, listener): + """Register a listener to be notified when data changes.""" + with self.__listener_lock: + if listener not in self.__listeners: + self.__listeners.append(listener) + + def unregister_listener(self, listener): + """Unregister a listener.""" + with self.__listener_lock: + if listener in self.__listeners: + self.__listeners.remove(listener) + + def get_xdata(self): + """Returns the x data (timestamps) as numpy array.""" + if isinstance(self.x, numpy.ndarray): + return self.x + return numpy.array(self.x, dtype=float) if self.x else numpy.array([], dtype=float) + + def get_ydata(self, channel=0): + """Returns the y data (temperature readings) as numpy array.""" + if channel == 0: + if isinstance(self.y, numpy.ndarray): + return self.y + return numpy.array(self.y, dtype=float) if self.y else numpy.array([], dtype=float) + return numpy.array([], dtype=float) + + def __len__(self): + """Returns the number of data points.""" + return len(self.y) if self.y else 0 + + def uses_statistics(self): + """Returns False as temperature data doesn't use statistics.""" + return False + + def ready_for_drawing_error(self): + """Returns False as temperature data doesn't have error bars.""" + return False + + def get_yerr(self, channel=0): + """Returns empty error data.""" + return numpy.array([], dtype=float) + + def get_errorplotdata(self): + """Returns empty error plot data.""" + return [[], [], []] + + def get_lineplotdata(self): + """Returns line plot data (not used for temperature).""" + return [[], []] + + def get_xmin(self): + """Returns minimum of x.""" + xdata = self.get_xdata() + return xdata.min() if len(xdata) > 0 else 0 + + def get_xmax(self): + """Returns maximum of x.""" + xdata = self.get_xdata() + return xdata.max() if len(xdata) > 0 else 0 + + def get_ymin(self): + """Returns minimum of y.""" + ydata = self.get_ydata(0) + return ydata.min() if len(ydata) > 0 else 0 + + def get_ymax(self): + """Returns maximum of y.""" + ydata = self.get_ydata(0) + return ydata.max() if len(ydata) > 0 else 0 + + def get_xminpos(self): + """Returns smallest positive value of x.""" + xdata = self.get_xdata() + mask = xdata > 0 + if numpy.any(mask): + return xdata[mask].min() + return 0 + + def get_yminpos(self): + """Returns smallest positive value of y.""" + ydata = self.get_ydata(0) + mask = ydata > 0 + if numpy.any(mask): + return ydata[mask].min() + return 0 + + def write_to_hdf(self, hdffile, where, name, title, complib=None, complevel=None): + """ + Write the temperature result to an HDF5 group. - if (x is None) and (y is None) and (desc is None) and (job_id is None) and (job_date is None): - pass + Stores x (timestamps), y (temperature readings), and setpoint arrays + as an HDF5 table, with metadata saved as group attributes. + """ + h5_table_format = { + "x": tables.Float64Col(), + "y": tables.Float64Col(), + "setpoint": tables.Float64Col(), + } + filter = None + if complib is not None: + if complevel is None: + complevel = 9 + filter = tables.Filters(complevel=complevel, complib=complib, shuffle=1) - elif (x is not None) and (y is not None) and (desc is not None) and (job_id is not None) and (job_date is not None): - pass + with self.__listener_lock: + xdata = numpy.array(self.x, dtype=float) if self.x else numpy.array([], dtype=float) + ydata = numpy.array(self.y, dtype=float) if self.y else numpy.array([], dtype=float) + spdata = numpy.array(self.setpoint, dtype=float) if self.setpoint else numpy.array([], dtype=float) - else: - raise ValueError("Wrong usage of __init__!") + # Use the length of the longest array; shorter ones are padded with 0 + n = max(len(xdata), len(ydata), 1) + if len(xdata) < n: + xdata = numpy.pad(xdata, (0, n - len(xdata)), constant_values=0.0) + if len(ydata) < n: + ydata = numpy.pad(ydata, (0, n - len(ydata)), constant_values=0.0) + if len(spdata) < n: + spdata = numpy.pad(spdata, (0, n - len(spdata)), constant_values=0.0) + + temp_table = hdffile.create_table( + where=where, name=name, + description=h5_table_format, + title=title, + filters=filter, + expectedrows=n, + ) + temp_table.flavor = "numpy" + temp_table.attrs.damaris_type = "TemperatureResult" + temp_table.attrs.xlabel = self.xlabel + temp_table.attrs.ylabel = self.ylabel + + if hasattr(self, "job_id") and self.job_id is not None: + temp_table.attrs.job_id = self.job_id + if hasattr(self, "job_date") and self.job_date is not None: + temp_table.attrs.time = "%04d%02d%02d %02d:%02d:%02d.%03d" % ( + self.job_date.year, + self.job_date.month, + self.job_date.day, + self.job_date.hour, + self.job_date.minute, + self.job_date.second, + self.job_date.microsecond // 1000, + ) + if hasattr(self, "description") and self.description is not None: + for (key, value) in self.description.items(): + temp_table.attrs.__setattr__("description_" + key, str(value)) + + row = temp_table.row + for i in range(n): + row["x"] = xdata[i] + row["y"] = ydata[i] + row["setpoint"] = spdata[i] + row.append() + + temp_table.flush() + + def __repr__(self): + """String representation of the temperature result.""" + return f"TemperatureResult(points={len(self)}, job_id={self.job_id})" + + +def read_from_hdf(hdf_node): + """ + Read a TemperatureResult object from an HDF5 table node. + Returns None if the node is not suitable. + """ + if not isinstance(hdf_node, tables.Table): + return None + + if hdf_node._v_attrs.damaris_type != "TemperatureResult": + return None + + tr = TemperatureResult() + tr.xlabel = getattr(hdf_node._v_attrs, "xlabel", "Time (s)") + tr.ylabel = getattr(hdf_node._v_attrs, "ylabel", "Temperature (C)") + + for r in hdf_node.iterrows(): + tr.x.append(r["x"]) + tr.y.append(r["y"]) + sp = r["setpoint"] + if sp != 0.0 or len(tr.setpoint) > 0: + tr.setpoint.append(sp) + + return tr diff --git a/src/damaris/experiments/DurationEstimator.py b/src/damaris/experiments/DurationEstimator.py new file mode 100644 index 0000000..575ae6b --- /dev/null +++ b/src/damaris/experiments/DurationEstimator.py @@ -0,0 +1,109 @@ +import io +import sys +import traceback +from damaris.experiments import Experiment + + +class DurationEstimator: + """ + Consumes an experiment script's generator and estimates total duration + without writing any files or invoking the backend. + + Sleeps and synchronizations in the script are tracked instead of + blocking, so their wall-clock time is included in the estimate. + """ + + def __init__(self): + pass + + def estimate(self, script): + """ + Run the experiment script in a sandbox and return a duration estimate. + + :param str script: experiment script source code + :returns: dict with 'experiment_time', 'sleep_time', 'total_time', and 'job_count' + :rtype: dict + """ + sleep_time = [0.0] # list so it's mutable in nested function + + def tracking_sleep(seconds): + sleep_time[0] += seconds + + def noop_sync(before=0, waitsteps=0.1): + pass + + def noop_data(): + return {} + + dataspace = {} + exp_classes = __import__('damaris.experiments', dataspace, dataspace, ['Experiment']) + for name in dir(exp_classes): + if name[:2] == "__" and name[-2:] == "__": + continue + dataspace[name] = exp_classes.__dict__[name] + del exp_classes + + # ranges module for lin_range, log_range, etc. + ranges = __import__('damaris.tools.ranges', dataspace, dataspace, []) + for name in dir(ranges): + if name[:2] == "__" and name[-2:] == "__": + continue + dataspace[name] = getattr(ranges, name) + del ranges + + # inject sandbox helpers (non-blocking versions) + dataspace["data"] = noop_data + dataspace["synchronize"] = noop_sync + dataspace["sleep"] = tracking_sleep + + # execute the script + try: + exec(script, dataspace) + except Exception as e: + traceback.print_exc() + raise RuntimeError("Script execution failed") from e + + if "experiment" not in dataspace: + raise ValueError("Script does not define an experiment() function") + + # consume the generator + try: + exp_iterator = dataspace["experiment"]() + except Exception as e: + traceback.print_exc() + raise RuntimeError("Calling experiment() failed") from e + + experiment_time = 0.0 + job_count = 0 + + while True: + try: + job = next(exp_iterator) + except StopIteration: + break + except Exception as e: + traceback.print_exc() + raise RuntimeError("Generator failed") from e + + experiment_time += job.get_length() + job_count += 1 + + total_time = experiment_time + sleep_time[0] + + return { + "experiment_time": experiment_time, + "sleep_time": sleep_time[0], + "total_time": total_time, + "job_count": job_count, + } + + +def estimate_duration(script): + """ + Convenience function to estimate experiment duration. + + :param str script: experiment script source code + :returns: dict with 'experiment_time', 'sleep_time', 'total_time', and 'job_count' + :rtype: dict + """ + return DurationEstimator().estimate(script) \ No newline at end of file diff --git a/src/damaris/gui/DamarisGUI.py b/src/damaris/gui/DamarisGUI.py index d97e610..82f486c 100644 --- a/src/damaris/gui/DamarisGUI.py +++ b/src/damaris/gui/DamarisGUI.py @@ -61,7 +61,8 @@ from damaris.gui import ResultReader, ResultHandling from damaris.gui import BackendDriver #from damaris.gui.gtkcodebuffer import CodeBuffer, SyntaxLoader #from damaris.data import Drawable # this is a base class, it should be used... -from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult +from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult, TemperatureResult +# TemperatureMonitor imported lazily to avoid circular import issues # default, can be set to true from start script debug = False @@ -180,6 +181,10 @@ class DamarisGUI: #to stop queued experiments self.stop_experiment_flag = threading.Event() + + # Temperature monitoring + self.temp_monitor = None + self.temp_controller = None exp_script = "" if exp_script_filename is not None and exp_script_filename != "": @@ -205,6 +210,7 @@ class DamarisGUI: #connect event handlers eventhandlers = {"on_toolbar_run_button_clicked": self.start_experiment, + "on_toolbar_estimate_button_clicked": self.estimate_duration, "on_toolbar_pause_button_toggled": self.pause_experiment, "on_toolbar_stop_button_clicked": self.stop_experiment, "on_doc_menu_activate": self.show_doc_menu, @@ -233,7 +239,6 @@ class DamarisGUI: "on_display_y_scaling_combobox_changed": self.monitor.display_scaling_changed, "on_display_save_data_as_text_button_clicked": self.monitor.save_display_data_as_text, "on_display_copy_data_to_clipboard_button_clicked": self.monitor.copy_display_data_to_clipboard, - "on_data_handling_textview_event": self.sw.textviews_modified, "on_experiment_script_textview_event": self.sw.textviews_modified, "on_main_notebook_switch_page": self.nopevent, "main_window_close": self.nopevent @@ -384,6 +389,117 @@ class DamarisGUI: # toolbar related events: + def estimate_duration( self, widget, data=None ): + """ + Estimate the total experiment duration and show a dialog. + """ + from datetime import datetime, timedelta + from damaris.experiments.DurationEstimator import estimate_duration + + exp_script, _ = self.sw.get_scripts() + + # validate syntax first + try: + compile(exp_script, "Experiment Script", "exec") + except SyntaxError as e: + ln = e.lineno if isinstance(e.lineno, int) else 0 + lo = e.offset if isinstance(e.offset, int) else 0 + msg = "Experiment Script: %s at line %d, col %d" % (e.__class__.__name__, ln, lo) + dialog = gtk.MessageDialog( + parent=self.main_window, + flags=gtk.DialogFlags.MODAL, + type=gtk.MessageType.ERROR, + buttons=gtk.ButtonsType.OK, + message_format=msg + ) + dialog.run() + dialog.destroy() + return + + # run estimator in a background thread to not freeze the GUI + dialog = gtk.MessageDialog( + parent=self.main_window, + flags=gtk.DialogFlags.MODAL, + type=gtk.MessageType.INFO, + buttons=gtk.ButtonsType.CLOSE, + message_format="Estimating duration..." + ) + dialog.set_resizable(False) + dialog.show() + + result = [None] + error = [None] + done = threading.Event() + + def run_estimate(): + try: + result[0] = estimate_duration(exp_script) + except Exception as e: + error[0] = str(e) + done.set() + + t = threading.Thread(target=run_estimate) + t.daemon = True + t.start() + + while not done.is_set(): + while gtk.events_pending(): + gtk.main_iteration_do(False) + if done.wait(0.1): + break + + dialog.hide() + dialog.destroy() + + if error[0] is not None: + dialog = gtk.MessageDialog( + parent=self.main_window, + flags=gtk.DialogFlags.MODAL, + type=gtk.MessageType.ERROR, + buttons=gtk.ButtonsType.OK, + message_format="Estimation failed: " + error[0] + ) + dialog.run() + dialog.destroy() + return + + r = result[0] + total = r["total_time"] + exp_time = r["experiment_time"] + sleep_t = r["sleep_time"] + jobs = r["job_count"] + + # format duration as HH:MM:SS + def fmt(seconds): + h = int(seconds) // 3600 + m = (int(seconds) % 3600) // 60 + s = int(seconds) % 60 + return "%02d:%02d:%02d" % (h, m, s) + + now = datetime.now() + end = now + timedelta(seconds=total) + end_str = end.strftime("%H:%M:%S") + + text = ( + "Jobs: %d\n" + "Pulse program: %s (%.2f s)\n" + "Sleeps: %s (%.2f s)\n" + "---------------------------\n" + "Total: %s\n\n" + "Start now:\n" + "Estimated end: %s" + ) % (jobs, fmt(exp_time), exp_time, fmt(sleep_t), sleep_t, fmt(total), end_str) + + dialog = gtk.MessageDialog( + parent=self.main_window, + flags=gtk.DialogFlags.MODAL, + type=gtk.MessageType.INFO, + buttons=gtk.ButtonsType.OK, + message_format=text + ) + dialog.run() + dialog.destroy() + def start_experiment( self, widget, data=None ): # something running? @@ -512,6 +628,8 @@ class DamarisGUI: self.data = self.si.data # run frontend and script engines self.monitor.observe_data_pool( self.data ) + # Start temperature monitoring if configured (before scripts start) + self.start_temperature_monitoring(actual_config) self.si.runScripts( ) except Exception as e: #print "ToDo evaluate exception",str(e), "at",traceback.extract_tb(sys.exc_info()[2])[-1][1:3] @@ -572,6 +690,7 @@ class DamarisGUI: self.dump_filename = "" if actual_config[ "data_pool_name" ] != "": self.dump_states( init=True ) + gobject.timeout_add( 200, self.observe_running_experiment ) def observe_running_experiment( self ): @@ -684,6 +803,8 @@ class DamarisGUI: 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: + # Stop temperature monitoring when experiment finishes + self.stop_temperature_monitoring() if self.save_thread is None and self.dump_filename != "": print("all subprocesses ended, saving data pool") # thread to save data... @@ -974,8 +1095,98 @@ class DamarisGUI: still_running = [_f for _f in [ self.si.exp_handling, self.si.res_handling, self.si.back_driver ] if _f] for r in still_running: r.quit_flag.set( ) + + # Stop temperature monitoring + self.stop_temperature_monitoring() + self.state = DamarisGUI.Stop_State + def start_temperature_monitoring(self, config): + """ + Start temperature monitoring thread if configured. + + Args: + config: Configuration dictionary from self.config.get() + """ + # Check if temperature monitoring is enabled + if not config.get("enable_temperature_monitoring", False): + # Stop any existing monitor if temperature monitoring is disabled + self.stop_temperature_monitoring() + return + + # Stop any existing monitor before starting a new one + if self.temp_monitor is not None: + self.stop_temperature_monitoring() + + controller_type = config.get("temperature_controller_type", "eurotherm") + temp_interval = config.get("temperature_interval", 2.0) + + try: + # Import the factory and TemperatureMonitor (lazy import to avoid circular issues) + from damaris.tools.temperature import create_controller + from damaris.gui.TemperatureMonitor import TemperatureMonitor + + # Create controller based on type + if controller_type == "eurotherm": + temp_device = config.get("temperature_device", "") + temp_baudrate = config.get("temperature_baudrate", 19200) + + if not temp_device: + print("Temperature monitoring: No device configured for Eurotherm") + return + + self.temp_controller = create_controller( + "eurotherm", temp_device, baudrate=temp_baudrate + ) + print(f"Temperature monitoring started with Eurotherm on {temp_device} (interval: {temp_interval}s)") + + elif controller_type == "simulated": + # Simulated controller doesn't need device configuration + # Use default parameters: 290K base, 10K amplitude, 60s period, 5% noise, 10% missing + self.temp_controller = create_controller("simulated") + print(f"Temperature monitoring started with simulated controller (interval: {temp_interval}s)") + + else: + print(f"Temperature monitoring: Unknown controller type: {controller_type}") + return + + # Create and start monitor thread + self.temp_monitor = TemperatureMonitor( + controller=self.temp_controller, + interval=temp_interval, + data_pool=self.data, + data_key="temperature" + ) + self.temp_monitor.start() + + except Exception as e: + print(f"Failed to start temperature monitoring: {e}") + import traceback + traceback.print_exc() + self.temp_controller = None + self.temp_monitor = None + + def stop_temperature_monitoring(self): + """ + Stop the temperature monitoring thread if running. + """ + if self.temp_monitor is not None: + try: + self.temp_monitor.stop() + print("Temperature monitoring stopped") + except Exception as e: + print(f"Error stopping temperature monitoring: {e}") + finally: + self.temp_monitor = None + + if self.temp_controller is not None: + try: + self.temp_controller.close() + except Exception as e: + print(f"Error closing temperature controller: {e}") + finally: + self.temp_controller = None + def documentation_init( self ): self.doc_urls = { @@ -1154,7 +1365,7 @@ class ScriptWidgets: class import from as return yield while continue break assert None True False AccumulatedValue Accumulation MeasurementResult ADC_Result Experiment synchronize sleep result data issubclass min max abs pow range xrange log_range lin_range staggered_range file -combine_ranges interleaved_range get_sampling_rate uses_statistics write_to_csv write_to_hdf +combine_ranges interleaved_range get_sampling_rate uses_statistics write_to_csv write_to_hdf lowpass get_job_id get_description set_description get_xdata get_ydata set_xdata set_ydata ttl_pulse ttls rf_pulse state_start state_end wait record loop_start loop_end set_frequency set_pfg set_dac set_phase set_pts_local get_length set_title get_title add_lineplotdata @@ -2044,6 +2255,13 @@ class ConfigTab: self.config_data_pool_comprate.set_tooltip_text("Use 1 for best compression, 9 for best speed; 3 is very usable") self.config_info_textview = self.xml_gui.get_object( "info_textview" ) self.config_script_font_button = self.xml_gui.get_object( "script_fontbutton" ) + + # Connect keyboard event handlers for Ctrl+S support + self.config_info_textview.connect("key-press-event", self.config_keypress) + self.config_backend_executable_entry.connect("key-press-event", self.config_keypress) + self.config_spool_dir_entry.connect("key-press-event", self.config_keypress) + self.config_data_pool_name_entry.connect("key-press-event", self.config_keypress) + self.config_data_pool_write_interval_entry.connect("key-press-event", self.config_keypress) # ADC bit depth setting self.config_adc_bit_depth_hbox = gtk.HBox(homogeneous=False, spacing=6) @@ -2052,6 +2270,7 @@ class ConfigTab: self.config_adc_bit_depth_spinbutton.set_value(ADC_Result.default_bit_depth) self.config_adc_bit_depth_spinbutton.set_tooltip_text("Set the default bit depth for clipping detection (e.g., 14 for 14-bit ADCs).") self.config_adc_bit_depth_spinbutton.connect("value-changed", self.on_adc_bit_depth_changed) + self.config_adc_bit_depth_spinbutton.connect("key-press-event", self.config_keypress) self.config_adc_bit_depth_hbox.pack_start(self.config_adc_bit_depth_label, False, False, 0) self.config_adc_bit_depth_hbox.pack_start(self.config_adc_bit_depth_spinbutton, False, False, 0) backend_vbox = self.xml_gui.get_object("vbox4") @@ -2059,6 +2278,75 @@ class ConfigTab: backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0) self.config_adc_bit_depth_hbox.show_all() + # Temperature monitoring settings - single line layout + self.config_temp_hbox = gtk.HBox(homogeneous=False, spacing=6) + + # Controller type selector + self.config_temp_controller_type_label = gtk.Label(label="Controller:") + self.config_temp_controller_type_combobox = gtk.ComboBoxText() + self.config_temp_controller_type_combobox.append_text("eurotherm") + self.config_temp_controller_type_combobox.append_text("simulated") + self.config_temp_controller_type_combobox.set_active(0) # Default to eurotherm + self.config_temp_controller_type_combobox.set_tooltip_text("Select temperature controller type") + self.config_temp_controller_type_combobox.connect("changed", self.on_temp_controller_type_changed) + + self.config_temp_controller_type_hbox = gtk.HBox(homogeneous=False, spacing=0) + self.config_temp_controller_type_hbox.pack_start(self.config_temp_controller_type_label, False, False, 0) + self.config_temp_controller_type_hbox.pack_start(self.config_temp_controller_type_combobox, False, False, 0) + self.config_temp_hbox.pack_start(self.config_temp_controller_type_hbox, False, False, 0) + + # Enable checkbox + self.config_temp_monitoring_checkbutton = gtk.CheckButton(label="Temp:") + self.config_temp_monitoring_checkbutton.set_tooltip_text("Enable periodic temperature reading from controller") + self.config_temp_hbox.pack_start(self.config_temp_monitoring_checkbutton, False, False, 0) + + # Device file chooser with entry (shown for eurotherm, hidden for simulated) + self.config_temp_device_entry = gtk.Entry() + self.config_temp_device_entry.set_tooltip_text("Serial port device (e.g., /dev/serial/by-id/...)") + self.config_temp_device_entry.set_width_chars(30) + self.config_temp_device_entry.connect("key-press-event", self.config_keypress) + self.config_temp_device_button = gtk.Button(label="...") + self.config_temp_device_button.set_tooltip_text("Browse for temperature controller device") + self.config_temp_device_button.connect("clicked", self.on_temp_device_browse_clicked) + + self.config_temp_device_hbox = gtk.HBox(homogeneous=False, spacing=0) + self.config_temp_device_hbox.pack_start(self.config_temp_device_entry, True, True, 0) + self.config_temp_device_hbox.pack_start(self.config_temp_device_button, False, False, 0) + self.config_temp_hbox.pack_start(self.config_temp_device_hbox, True, True, 0) + + # Interval spinbutton + self.config_temp_interval_label = gtk.Label(label="Interval (s):") + self.config_temp_interval_spinbutton = gtk.SpinButton.new_with_range(0.1, 60, 0.1) + self.config_temp_interval_spinbutton.set_value(2.0) # Default to 2 seconds + self.config_temp_interval_spinbutton.set_tooltip_text("Time between temperature readings in seconds") + self.config_temp_interval_spinbutton.connect("key-press-event", self.config_keypress) + + self.config_temp_interval_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_temp_interval_hbox.pack_start(self.config_temp_interval_label, False, False, 0) + self.config_temp_interval_hbox.pack_start(self.config_temp_interval_spinbutton, False, False, 0) + self.config_temp_hbox.pack_start(self.config_temp_interval_hbox, False, False, 0) + + # Baud rate (for eurotherm) + self.config_temp_baudrate_combobox = gtk.ComboBoxText() + self.config_temp_baudrate_combobox.append_text("19200") + self.config_temp_baudrate_combobox.append_text("9600") + self.config_temp_baudrate_combobox.append_text("38400") + self.config_temp_baudrate_combobox.append_text("57600") + self.config_temp_baudrate_combobox.append_text("115200") + self.config_temp_baudrate_combobox.set_active(0) + self.config_temp_baudrate_combobox.set_tooltip_text("Serial baud rate for temperature controller") + self.config_temp_baudrate_hbox = gtk.HBox(homogeneous=False, spacing=6) + self.config_temp_baudrate_hbox.pack_start(self.config_temp_baudrate_combobox, False, False, 0) + self.config_temp_hbox.pack_start(self.config_temp_baudrate_hbox, False, False, 0) + # Note: baud rate is shown for eurotherm, hidden for simulated + + # Set initial visibility based on controller type + self._update_temp_widgets_visibility() + + if backend_vbox: + backend_vbox.pack_start(self.config_temp_hbox, False, False, 0) + self.config_temp_hbox.show_all() + # insert version informations components_text = """ operating system %(os)s @@ -2165,10 +2453,28 @@ pygobject version %(pygobject)s "data_pool_complib": complib, "data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ), "script_font": self.config_script_font_button.get_font_name( ), - "adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int() + "adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int(), + # Temperature monitoring configuration (only if widgets exist) + "enable_temperature_monitoring": self.config_temp_monitoring_checkbutton.get_active() if hasattr(self, 'config_temp_monitoring_checkbutton') else False, + "temperature_controller_type": self.config_temp_controller_type_combobox.get_active_text() if hasattr(self, 'config_temp_controller_type_combobox') else "eurotherm", + "temperature_device": self.config_temp_device_entry.get_text() if hasattr(self, 'config_temp_device_entry') else "", + "temperature_baudrate": self._get_temp_baudrate() if hasattr(self, 'config_temp_baudrate_combobox') else 19200, + "temperature_interval": self.config_temp_interval_spinbutton.get_value() if hasattr(self, 'config_temp_interval_spinbutton') else 2.0 } ADC_Result.default_bit_depth = actual_config["adc_bit_depth"] return actual_config + + def _get_temp_baudrate(self): + """Get selected baud rate from combobox.""" + if not hasattr(self, 'config_temp_baudrate_combobox'): + return 19200 + active_text = self.config_temp_baudrate_combobox.get_active_text() + if active_text: + try: + return int(active_text) + except ValueError: + pass + return 19200 def set( self, config ): if "start_backend" in config: @@ -2200,6 +2506,41 @@ pygobject version %(pygobject)s ADC_Result.default_bit_depth = int(config["adc_bit_depth"]) except (ValueError, TypeError): pass + + # Temperature monitoring configuration (only if widgets exist) + if hasattr(self, 'config_temp_monitoring_checkbutton') and "enable_temperature_monitoring" in config: + try: + self.config_temp_monitoring_checkbutton.set_active(bool(config["enable_temperature_monitoring"])) + except (TypeError, ValueError): + pass + if hasattr(self, 'config_temp_controller_type_combobox') and "temperature_controller_type" in config: + try: + controller_type = config["temperature_controller_type"] + if controller_type in ["eurotherm", "simulated"]: + self._set_combo_active_text(self.config_temp_controller_type_combobox, controller_type) + # Update widget visibility based on type + self._update_temp_widgets_visibility() + except (TypeError, ValueError): + pass + if hasattr(self, 'config_temp_device_entry') and "temperature_device" in config: + try: + self.config_temp_device_entry.set_text(str(config["temperature_device"])) + except (TypeError, ValueError): + pass + if hasattr(self, 'config_temp_baudrate_combobox') and "temperature_baudrate" in config: + try: + baudrate = str(config["temperature_baudrate"]) + if baudrate in ["9600", "19200", "38400", "57600", "115200"]: + self._set_combo_active_text(self.config_temp_baudrate_combobox, baudrate) + else: + self.config_temp_baudrate_combobox.set_active(0) # Default to 19200 + except (TypeError, ValueError): + pass + if hasattr(self, 'config_temp_interval_spinbutton') and "temperature_interval" in config: + try: + self.config_temp_interval_spinbutton.set_value(float(config["temperature_interval"])) + except (ValueError, TypeError): + self.config_temp_interval_spinbutton.set_value(2.0) if "data_pool_complib" in config: # find combo-box entry and make it active... model = self.config_data_pool_complib.get_model( ) @@ -2216,6 +2557,84 @@ pygobject version %(pygobject)s def on_adc_bit_depth_changed(self, widget): ADC_Result.default_bit_depth = widget.get_value_as_int() + def on_temp_device_browse_clicked(self, widget): + """ + Open file chooser dialog to select temperature controller device. + Defaults to /dev/serial/by-id/ which is typically used for USB serial converters. + """ + dialog = gtk.FileChooserDialog( + title="Select Temperature Controller Device", + parent=None, + action=gtk.FileChooserAction.OPEN, + buttons=( + gtk.STOCK_CANCEL, gtk.ResponseType.CANCEL, + gtk.STOCK_OPEN, gtk.ResponseType.OK + ) + ) + + # Set default folder to /dev/serial/by-id/ if it exists + default_folder = "/dev/serial/by-id/" + if os.path.isdir(default_folder): + dialog.set_current_folder(default_folder) + else: + # Fallback to /dev + dialog.set_current_folder("/dev/") + + # Only show regular files (device files) + dialog.set_select_multiple(False) + + response = dialog.run() + if response == gtk.ResponseType.OK: + selected_file = dialog.get_filename() + self.config_temp_device_entry.set_text(selected_file) + + dialog.destroy() + + def on_temp_controller_type_changed(self, widget): + """Update widget visibility when controller type changes.""" + self._update_temp_widgets_visibility() + + def _update_temp_widgets_visibility(self): + """Show/hide widgets based on selected controller type.""" + # Defensive: check if combobox exists + if not hasattr(self, 'config_temp_controller_type_combobox'): + return + + active_type = self.config_temp_controller_type_combobox.get_active_text() + if active_type is None: + active_type = "eurotherm" + + # Defensive: check if widgets exist before showing/hiding + show_device = active_type == "eurotherm" + if hasattr(self, 'config_temp_device_hbox'): + if show_device: + self.config_temp_device_hbox.show() + else: + self.config_temp_device_hbox.hide() + if hasattr(self, 'config_temp_baudrate_hbox'): + if show_device: + self.config_temp_baudrate_hbox.show() + else: + self.config_temp_baudrate_hbox.hide() + + def _set_combo_active_text(self, combobox, text): + """ + Set a ComboBoxText's active entry by text. + + Gtk.ComboBoxText.set_active_text() does not exist in GTK 3.24.x, + so we iterate the model to find the matching entry. + """ + if not text: + return + model = combobox.get_model() + if model is None: + return + for i in range(model.iter_n_children(None)): + iter = model.iter_nth_child(None, i) + if iter and model.get(iter, 0)[0] == text: + combobox.set_active(i) + return + def load_config_handler( self, widget ): if self.system_default_filename: self.load_config( self.system_default_filename ) @@ -2224,6 +2643,17 @@ pygobject version %(pygobject)s def save_config_handler( self, widget ): self.save_config( ) + def config_keypress( self, widget, event ): + """ + Handle keyboard shortcuts in configuration window + """ + if event.state & gdk.ModifierType.CONTROL_MASK != 0: + if event.keyval == gdk.keyval_from_name( "s" ): + # Save configuration with Ctrl+S + self.save_config( ) + return True + return False + def set_script_font_handler( self, widget ): """ handles changes in font name @@ -3115,7 +3545,7 @@ class MonitorWidgets: if in_result is None: self.clear_display( ) return - if isinstance( in_result, Accumulation ) or isinstance( in_result, ADC_Result ): + if isinstance( in_result, Accumulation ) or isinstance( in_result, ADC_Result ) or isinstance( in_result, TemperatureResult ): xmin = in_result.get_xmin( ) xmax = in_result.get_xmax( ) @@ -3463,7 +3893,6 @@ class ScriptInterface: self.data = DataPool( ) def runScripts( self ): - try: # get script engines self.exp_handling = self.res_handling = None diff --git a/src/damaris/gui/TemperatureMonitor.py b/src/damaris/gui/TemperatureMonitor.py new file mode 100644 index 0000000..d945b70 --- /dev/null +++ b/src/damaris/gui/TemperatureMonitor.py @@ -0,0 +1,206 @@ +# -*- coding: iso-8859-1 -*- +""" +Temperature monitoring thread. + +This module provides a thread that periodically reads temperature from a controller +and stores it in a TemperatureResult object. +""" + +import threading +import time +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): + """ + Thread that periodically reads temperature from a controller + and stores it in a TemperatureResult object. + + This thread runs independently and can be configured with any + TemperatureController implementation (Eurotherm, Lakeshore, etc.). + + Attributes: + controller: The TemperatureController instance to read from. + interval: Polling interval in seconds. + result: The TemperatureResult object storing collected data. + quit_flag: Event to signal the thread to stop. + data_pool: Optional DataPool to store results. + data_key: Key to use when storing in data_pool. + """ + + def __init__(self, controller: TemperatureController, + interval: float = 1.0, + data_pool=None, + data_key: str = "temperature"): + """ + Initialize the temperature monitor. + + Args: + controller: TemperatureController implementation (Eurotherm, etc.) + interval: Polling interval in seconds (default: 1.0) + data_pool: Optional DataPool to store results (default: None) + data_key: Key to use when storing in data_pool (default: "temperature") + """ + threading.Thread.__init__(self, name="TemperatureMonitor") + self.controller = controller + self.interval = float(interval) + self.data_pool = data_pool + self.data_key = data_key + self.quit_flag = threading.Event() + self.result = TemperatureResult() + self._running = False + self._lock = threading.RLock() + + # Initialize the result with metadata + self.result.xlabel = "Time (s)" + self.result.ylabel = "Temperature (C)" + self.result.description = {"type": "temperature_monitoring"} + + def run(self): + """ + Main thread loop: periodically reads temperature and stores it. + """ + self._running = True + start_time = time.time() + + while not self.quit_flag.is_set() and self._running: + try: + # Read temperature and setpoint + timestamp = time.time() - start_time + current_temp = self.controller.get_temperature() + setpoint = self.controller.get_setpoint() + + # Store in result + with self._lock: + if not hasattr(self.result, 'x'): + self.result.x = [] + self.result.y = [] + self.result.setpoint = [] + + self.result.x.append(timestamp) + self.result.y.append(current_temp) + self.result.setpoint.append(setpoint) + + # Update job metadata on first sample + if len(self.result.x) == 1: + self.result.job_date = datetime.datetime.now() + self.result.job_id = id(self) + + # Store result in data_pool (thread-safe for GTK) + # Replace the entry on each update to trigger DataPool events + if self.data_pool is not None: + with self._lock: + # Create a copy for the data_pool with current data + data_pool_result = TemperatureResult() + data_pool_result.xlabel = self.result.xlabel + data_pool_result.ylabel = self.result.ylabel + data_pool_result.description = self.result.description.copy() if isinstance(self.result.description, dict) else {} + # Copy the data from self.result + data_pool_result.x = list(self.result.x) if hasattr(self.result, 'x') else [] + data_pool_result.y = list(self.result.y) if hasattr(self.result, 'y') else [] + data_pool_result.setpoint = list(self.result.setpoint) if hasattr(self.result, 'setpoint') else [] + 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 + + except Exception as e: + print(f"TemperatureMonitor error: {e}") + import traceback + traceback.print_exc() + + # Sleep in small increments for responsive shutdown + sleep_remaining = self.interval + while sleep_remaining > 0 and not self.quit_flag.is_set() and self._running: + sleep_time = min(0.1, sleep_remaining) + self.quit_flag.wait(sleep_time) + sleep_remaining -= sleep_time + + def stop(self): + """ + Graceful shutdown: signal the thread to stop and wait for it. + """ + self._running = False + self.quit_flag.set() + # Wait for the thread to finish, with a generous timeout + # Use a longer timeout to account for potential delays + if self.is_alive(): + self.join(timeout=5.0) # Wait up to 5 seconds + if self.is_alive(): + print("Warning: TemperatureMonitor thread did not stop gracefully") + # Thread is still running, but we can't force stop it + # The next start will check if it's alive and handle it + + def reset(self): + """ + Clear the collected data and start fresh. + """ + with self._lock: + self.result = TemperatureResult() + self.result.xlabel = "Time (s)" + self.result.ylabel = "Temperature (C)" + self.result.description = {"type": "temperature_monitoring"} + + def get_current_temperature(self) -> float: + """ + Get the latest temperature reading. + + Returns: + float: Latest temperature in Celsius, or 0.0 if no data. + """ + with self._lock: + if hasattr(self.result, 'y') and len(self.result.y) > 0: + return self.result.y[-1] + return 0.0 + + def get_current_setpoint(self) -> float: + """ + Get the latest setpoint reading. + + Returns: + float: Latest setpoint in Celsius, or 0.0 if no data. + """ + with self._lock: + if hasattr(self.result, 'setpoint') and len(self.result.setpoint) > 0: + return self.result.setpoint[-1] + return 0.0 + + def is_running(self) -> bool: + """ + Check if the monitor is currently running. + + Returns: + bool: True if the monitor thread is active. + """ + return self._running and not self.quit_flag.is_set() diff --git a/src/damaris/gui/damaris3.glade b/src/damaris/gui/damaris3.glade index a91e401..8ce87fe 100644 --- a/src/damaris/gui/damaris3.glade +++ b/src/damaris/gui/damaris3.glade @@ -641,6 +641,21 @@ Public License instead of this License. True + + + True + False + Estimate experiment duration + Estimate + True + dialog-information + + + + False + True + + True @@ -909,7 +924,6 @@ Public License instead of this License. True True - diff --git a/src/damaris/gui/python.xml b/src/damaris/gui/python.xml index 031a9b7..d9c24c2 100644 --- a/src/damaris/gui/python.xml +++ b/src/damaris/gui/python.xml @@ -191,6 +191,7 @@ set_pts_local wait record + lowpass diff --git a/src/damaris/tools/eurotherm.py b/src/damaris/tools/eurotherm.py index fe34b98..d231779 100644 --- a/src/damaris/tools/eurotherm.py +++ b/src/damaris/tools/eurotherm.py @@ -3,6 +3,8 @@ import re import operator from functools import reduce +from .temperature import TemperatureController, register_controller + DEBUG=False @@ -40,7 +42,7 @@ def checksum(message): bcc = (reduce(operator.xor, list(map(ord,message)))) return chr(bcc) -class Eurotherm(object): +class Eurotherm(TemperatureController): def __init__(self, serial_device, baudrate = 19200): self.device = standard_device # timeout: 110 ms to get all answers. @@ -100,6 +102,39 @@ class Eurotherm(object): def get_setpoint_temperature(self): return self.read_param('SL') + # TemperatureController interface implementation + + def get_temperature(self) -> float: + """Read current temperature in Celsius.""" + temp = self.read_param('PV') + if temp is None: + return 0.0 + try: + return float(temp) + except (ValueError, TypeError): + return 0.0 + + def get_setpoint(self) -> float: + """Read target setpoint temperature in Celsius.""" + temp = self.read_param('SL') + if temp is None: + return 0.0 + try: + return float(temp) + except (ValueError, TypeError): + return 0.0 + + def set_setpoint(self, temperature: float) -> bool: + """Set target temperature in Celsius.""" + return self.write_param('SL', str(temperature)) + + def close(self): + """Close the serial connection.""" + if hasattr(self, 's') and self.s is not None: + if self.s.is_open: + self.s.close() + self.s = None + if __name__ == '__main__': import time delta=5 @@ -118,3 +153,6 @@ if __name__ == '__main__': f.write(l) f.flush() f.write('# MARK -- %s --\n'%(time.asctime())) + +# Register Eurotherm with the factory +register_controller('eurotherm', Eurotherm) diff --git a/src/damaris/tools/lakeshore.py b/src/damaris/tools/lakeshore.py new file mode 100644 index 0000000..561947d --- /dev/null +++ b/src/damaris/tools/lakeshore.py @@ -0,0 +1,149 @@ +import serial +import re +import numpy as np + +from .temperature import TemperatureController, register_controller + + +DEBUG = False + +reply_pattern = re.compile(r"\x02..(.*)\x03.", re.DOTALL) +# example answer '\x02PV279.8\x03/' +# [EOT] = \x04 +# [STX] = \x02 +# [ENQ] = \x05 +# [ETX] = \x03 +# [ACK] = \x06 +# BCC = checksum + +standard_device = '/dev/ttyUSB0' + + +""" +Parameter read example: + +Master: [EOT]0011PV[ENQ] +Instrument: [STX]PV16.4[ETX]{BCC} + +Writing data: + +Master: [EOT] {GID}{GID}{UID}{UID}[STX]{CHAN}(c1)(c2)[ETX](BCC) + +""" + + + +class Lakeshore(TemperatureController): + def __init__(self, serial_device, baudrate=57600): + self.device = standard_device + # timeout: 110 ms to get all answers. + self.s = serial.Serial(serial_device, + baudrate=baudrate, + bytesize=7, + parity='O', + stopbits=1, + timeout=0.4) + self._expect_len = 50 + + def __del__(self): + if hasattr(self, 's') and self.s is not None: + if self.s.is_open: + self.s.close() + + #def get_current_temperature(self): # Temp A + # self.s.write("KRDG? a\n") + # a = self.s.readline().strip()[1:] + # return float(a) + + def get_temperature_A(self): # Temp A + self.s.write("KRDG? a\n") + a = self.s.readline().strip()[1:] + return float(a) + + def get_temperature_B(self): # Temp B + self.s.write("KRDG? b\n") + b = self.s.readline().strip()[1:] + return float(b) + + def set_temperature(self, temperature): # setze Setpoint + t = temperature + self.s.write(f"SETP 1,{t}\n") + + def set_ramp(self, ramp): + return self.s.write(f'RAMP 1, 1, {ramp}\n') + + def get_set_temperature(self): # Setpoint abfrage + self.s.write("SETP? 1\n") + s = self.s.readline().strip()[1:] + return float(s) + + def get_output(self): # Heater Output + self.s.write("HTR? 1\n") + o = self.s.readline().strip()[1:] + return float(o) + + def get_ramp(self): + self.s.write('RAMP? 1\n') + ret = self.s.readline().strip() + onoff, rate = ret.split(',') + if onoff: + return float(rate) + else: + return 0.0 + + def read_param(self, param): + """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:] + return None + + def get_value(self, name): + temp = self.read_param(name) + if temp is None: + return np.nan + else: + try: + return float(temp) + except ValueError: + return np.nan + + # TemperatureController interface implementation + + def get_temperature(self) -> float: + """Read current temperature in Celsius.""" + temp = self.read_param('KRDG') + if temp is None: + return 0.0 + try: + return float(temp) + except (ValueError, TypeError): + return 0.0 + + def get_setpoint(self) -> float: + """Read target setpoint temperature in Celsius.""" + temp = self.read_param('SETP') + if temp is None: + return 0.0 + try: + return float(temp) + except (ValueError, TypeError): + return 0.0 + + def set_setpoint(self, temperature: float) -> bool: + """Set target temperature in Celsius.""" + self.s.write(f"SETP {temperature}\n") + return True + + def close(self): + """Close the serial connection.""" + if hasattr(self, 's') and self.s is not None: + if self.s.is_open: + self.s.close() + self.s = None + + +# Register Lakeshore with the factory +register_controller('lakeshore', Lakeshore) \ No newline at end of file diff --git a/src/damaris/tools/temperature.py b/src/damaris/tools/temperature.py new file mode 100644 index 0000000..a50c01d --- /dev/null +++ b/src/damaris/tools/temperature.py @@ -0,0 +1,151 @@ +# -*- coding: iso-8859-1 -*- +""" +Temperature controller interface and base classes. + +This module provides a consistent interface for temperature controllers +to enable pluggable temperature monitoring systems. +""" + +from abc import ABC, abstractmethod + +# Plugin registry for temperature controllers +_controller_registry = {} + + +def register_controller(name: str, controller_class): + """ + Register a temperature controller implementation. + + Args: + name: Unique identifier for this controller type + controller_class: Class that implements TemperatureController + """ + _controller_registry[name] = controller_class + + +def create_controller(name: str, *args, **kwargs): + """ + Create a temperature controller instance by name. + + Args: + name: Registered controller type name + *args: Positional arguments to pass to controller constructor + **kwargs: Keyword arguments to pass to controller constructor + + Returns: + TemperatureController: Instance of the requested controller + + Raises: + ValueError: If controller name is not registered and cannot be imported + """ + # If not found, try to auto-import the module + if name not in _controller_registry: + # Map known controller names to their modules + module_map = { + 'simulated': 'damaris.tools.temperature_simulator', + 'eurotherm': 'damaris.tools.eurotherm', + 'lakeshore': 'damaris.tools.lakeshore', + } + if name in module_map: + try: + # Import the module which will register the controller + import importlib + importlib.import_module(module_map[name]) + except ImportError as e: + raise ValueError(f"Failed to import controller '{name}': {e}. Available: {list(_controller_registry.keys())}") + + # Check again after import + if name not in _controller_registry: + raise ValueError(f"Unknown temperature controller: {name}. Available: {list(_controller_registry.keys())}") + + return _controller_registry[name](*args, **kwargs) + + +def get_controller_names() -> list: + """ + Get list of available controller names. + + Returns: + list: List of registered controller type names + """ + return list(_controller_registry.keys()) + + +# Pre-register built-in controllers by importing their modules +# This ensures they're available without explicit imports +try: + from . import eurotherm +except ImportError as e: + # Eurotherm may require serial module which might not be installed + # That's OK, it will be imported on-demand if needed + pass + +try: + from . import lakeshore +except ImportError as e: + # Lakeshore may require serial module which might not be installed + # That's OK, it will be imported on-demand if needed + pass + +try: + from . import temperature_simulator +except ImportError as e: + # Simulator should always work (no external dependencies) + # But if it fails, it will be imported on-demand + pass + + +class TemperatureController(ABC): + """ + Abstract base class for temperature controllers. + + All temperature controller implementations (Eurotherm, Lakeshore, etc.) + should inherit from this class and implement the abstract methods. + """ + + @abstractmethod + def get_temperature(self) -> float: + """ + Read current temperature in Celsius. + + Returns: + float: Current temperature in degrees Celsius. + + Raises: + IOError: If communication with the device fails. + RuntimeError: If the device is not ready or returns invalid data. + """ + pass + + @abstractmethod + def get_setpoint(self) -> float: + """ + Read the target setpoint temperature in Celsius. + + Returns: + float: Target temperature in degrees Celsius. + """ + pass + + @abstractmethod + def set_setpoint(self, temperature: float) -> bool: + """ + Set the target temperature setpoint. + + Args: + temperature: Target temperature in degrees Celsius. + + Returns: + bool: True if the setpoint was successfully set, False otherwise. + """ + pass + + def close(self): + """ + Clean up resources (e.g., close serial connections). + + This method should be called when the controller is no longer needed. + The default implementation does nothing; subclasses should override + if they have resources to clean up. + """ + pass diff --git a/src/damaris/tools/temperature_simulator.py b/src/damaris/tools/temperature_simulator.py new file mode 100644 index 0000000..0a57b67 --- /dev/null +++ b/src/damaris/tools/temperature_simulator.py @@ -0,0 +1,126 @@ +# -*- coding: iso-8859-1 -*- +""" +Simulated temperature controller for testing. + +Provides a temperature controller that simulates real temperature readings +with configurable parameters including sinusoidal variation, noise, and missing values. +""" + +import time +import math +import random +import threading + +from .temperature import TemperatureController, register_controller + + +class SimulatedTemperatureController(TemperatureController): + """ + A simulated temperature controller that generates realistic temperature readings. + + This controller is useful for testing and development without requiring + physical hardware. It simulates temperature readings with: + - Base temperature around 290K (17C) + - Sinusoidal variation with amplitude of 10K + - 5% random noise + - 10% probability of missing values (returns previous value) + - 60 second period for the sine wave + + Attributes: + base_temp: Base temperature in Kelvin (default: 290.0) + amplitude: Amplitude of sinusoidal variation in Kelvin (default: 10.0) + period: Period of sine wave in seconds (default: 60.0) + noise_level: Fraction of random noise (default: 0.05 = 5%) + missing_probability: Probability of missing value (default: 0.10 = 10%) + """ + + def __init__(self, base_temp: float = 290.0, amplitude: float = 10.0, + period: float = 60.0, noise_level: float = 0.05, + missing_probability: float = 0.10): + """ + Initialize the simulated temperature controller. + + Args: + base_temp: Base temperature in Kelvin (default: 290.0) + amplitude: Amplitude of sinusoidal variation in Kelvin (default: 10.0) + period: Period of sine wave in seconds (default: 60.0) + noise_level: Fraction of random noise (default: 0.05 = 5%) + missing_probability: Probability of missing value (default: 0.10 = 10%) + """ + self.base_temp = float(base_temp) + self.amplitude = float(amplitude) + self.period = float(period) + self.noise_level = float(noise_level) + self.missing_probability = float(missing_probability) + + self._start_time = time.time() + self._lock = threading.RLock() + self._setpoint = self.base_temp + self._last_temp = self.base_temp + self._last_reading_time = self._start_time + + def get_temperature(self) -> float: + """ + Get simulated current temperature in Kelvin. + + Returns: + float: Simulated temperature in Kelvin + """ + with self._lock: + elapsed = time.time() - self._start_time + current_time = time.time() + + # Calculate ideal sinusoidal temperature + temp = self.base_temp + self.amplitude * math.sin(2 * math.pi * elapsed / self.period) + + # Add 5% random noise + noise = temp * self.noise_level * (random.random() * 2 - 1) # -5% to +5% + temp += noise + + # 10% chance of missing value (return last good value) + if random.random() < self.missing_probability: + temp = self._last_temp + else: + self._last_temp = temp + self._last_reading_time = current_time + + return temp + + def get_setpoint(self) -> float: + """ + Get the target setpoint temperature in Kelvin. + + Returns: + float: Target temperature in Kelvin + """ + with self._lock: + return self._setpoint + + def set_setpoint(self, temperature: float) -> bool: + """ + Set the target temperature setpoint. + + Args: + temperature: Target temperature in Kelvin + + Returns: + bool: True if successful, False otherwise + """ + with self._lock: + self._setpoint = float(temperature) + return True + + def close(self): + """Clean up resources.""" + pass + + def reset(self): + """Reset the simulation to initial state.""" + with self._lock: + self._start_time = time.time() + self._last_temp = self.base_temp + self._last_reading_time = self._start_time + + +# Register the simulated controller with the factory +register_controller('simulated', SimulatedTemperatureController)