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
+
+
+
+ False
+ 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)