Merge branch 'main' of gitea.pkm.physik.tu-darmstadt.de:IPKM/python3-damaris
This commit is contained in:
2026-07-13 13:03:39 +02:00
19 changed files with 1676 additions and 98 deletions
+1 -3
View File
@@ -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
+2
View File
@@ -9,3 +9,5 @@ __pycache__
debian/files
.pybuild
.junie
debian/debhelper-build-stamp
debian/.debhelper
+9
View File
@@ -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
+37
View File
@@ -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"]
+5 -4
View File
@@ -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"},
+117 -28
View File
@@ -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()
+29 -24
View File
@@ -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()
+7 -1
View File
@@ -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
+20 -22
View File
@@ -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):
+218 -8
View File
@@ -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
@@ -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)
+435 -6
View File
@@ -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
+206
View File
@@ -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()
+15 -1
View File
@@ -641,6 +641,21 @@ Public License instead of this License.
<property name="homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="toolbar_estimate_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">Estimate experiment duration</property>
<property name="label">Estimate</property>
<property name="use-underline">True</property>
<property name="icon-name">dialog-information</property>
<signal name="clicked" handler="on_toolbar_estimate_button_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkToolItem" id="toolitem4">
<property name="visible">True</property>
@@ -909,7 +924,6 @@ Public License instead of this License.
<object class="GtkSourceView" id="data_handling_textview">
<property name="visible">True</property>
<property name="can-focus">True</property>
<signal name="event" handler="on_data_handling_textview_event" swapped="no"/>
</object>
</child>
</object>
+1
View File
@@ -191,6 +191,7 @@
<keyword>set_pts_local</keyword>
<keyword>wait</keyword>
<keyword>record</keyword>
<keyword>lowpass</keyword>
</keywordlist>
<keywordlist style="datatype">
+39 -1
View File
@@ -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)
+149
View File
@@ -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)<DATA>[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)
+151
View File
@@ -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
+126
View File
@@ -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)