Replace temperature monitoring with proxy history

This commit is contained in:
markusro committed 2026-10-21 00:08:26 +02:00
1 parent bd060dc4d0
commit 0e796a800b
11 files changed
+170 -1124

No files matched your search

+1 -5
View File
@@ -70,7 +70,7 @@ The GUI module contains the graphical interface, backend driver, and experiment/
damaris.gui.ExperimentWriter damaris.gui.ExperimentWriter
damaris.gui.ResultHandling damaris.gui.ResultHandling
damaris.gui.ResultReader damaris.gui.ResultReader
damaris.gui.TemperatureMonitor damaris.gui.TemperatureProxyClient
damaris.gui.logging_utils damaris.gui.logging_utils
A short usage example for the font helper of the GUI module (the non-deprecated A short usage example for the font helper of the GUI module (the non-deprecated
@@ -97,11 +97,7 @@ The tools module contains hardware driver interfaces and utility functions.
:toctree: _autosummary :toctree: _autosummary
:recursive: :recursive:
damaris.tools.eurotherm
damaris.tools.flow_control damaris.tools.flow_control
damaris.tools.goniometer damaris.tools.goniometer
damaris.tools.lakeshore
damaris.tools.ranges damaris.tools.ranges
damaris.tools.signal damaris.tools.signal
damaris.tools.temperature
damaris.tools.temperature_simulator
+6 -1
View File
@@ -6,7 +6,7 @@ DAMARIS for Experimenters
The DArmstadt MAgnetic Resonance Instrument Software is an in-house developed open-source spectrometer control software which gives every scientist a very versatile and highly flexible instrumentation tool to operate and design novel and/or complex NMR experiments. By using standard pulse programmers and data acquisition PCI and PCIe computer cards this allows any research group a way to build NMR spectrometers in a very cost effective way. The DArmstadt MAgnetic Resonance Instrument Software is an in-house developed open-source spectrometer control software which gives every scientist a very versatile and highly flexible instrumentation tool to operate and design novel and/or complex NMR experiments. By using standard pulse programmers and data acquisition PCI and PCIe computer cards this allows any research group a way to build NMR spectrometers in a very cost effective way.
Using open-source computer software to operate the hardware enables the user to get a very detailed understanding and a high level of control over the measurement data acquisition procedure. Unlike many home-built spectrometers and control software which are used specifically and tied to one spectrometer, DAMARIS was designed from the ground up for a flexible use in many different types of spectrometers. Another major design goal was to make the control hardware characteristics transparent to the spectrometer developer. In our group in Darmstadt DAMARIS is used on 9 different spectrometers ranging from Mechanical and Fast Electronic Field Cycling, Static Field Gradient (SFG) NMR as well as Pulsed Field Gradient (PFG) and 2H spectrometers. It is used successfully to measure multidimensional 2H NMR exchange spectra as well as for using shaped pulses (radio frequency and PFG) in other experiments. Beside the use in Darmstadt, DAMARIS is also in use at the Universities of Dortmund and Düsseldorf for time-domain, SFG and PFG spectrometers. Using open-source computer software to operate the hardware enables the user to get a very detailed understanding and a high level of control over the measurement data acquisition procedure. Unlike many home-built spectrometers and control software which are used specifically and tied to one spectrometer, DAMARIS was designed from the ground up for a flexible use in many different types of spectrometers. Another major design goal was to make the control hardware characteristics transparent to the spectrometer developer. In our group in Darmstadt DAMARIS is used on 9 different spectrometers ranging from Mechanical and Fast Electronic Field Cycling, Static Field Gradient (SFG) NMR as well as Pulsed Field Gradient (PFG) and 2H spectrometers. It is used successfully to measure multidimensional 2H NMR exchange spectra as well as for using shaped pulses (radio frequency and PFG) in other experiments. Beside the use in Darmstadt, DAMARIS is also in use at the Universities of Dortmund and Düsseldorf for time-domain, SFG and PFG spectrometers.
The front end of DAMARIS is user friendly and the usage is not difficult to learn. There is introductory documentation available [1]. The pulse programs are defined and written in Python which is, again, a very user friendly interpreted programming language. Using a full-fledged programming language with a huge amount of freely available libraries and a very active scientific community is a key advantage of using DAMARIS. The use of the scipy and numpy libraries (similar to Matlab) for numerical analysis and data treatment makes it possible, for example, to pre- or post-process data at single-shot or accumulation level using custom filters. Leveraging Python modules for serial communication permits DAMARIS to control auxiliary hardware like temperature controllers of different make and models (Lakeshore, Cryovac, Oxford Instruments, other RS232, Modbus, and GPIB controlled devices), oscilloscopes, magnet drift compensation controller, microprocessors (Arduino, etc.), step motor/piezo positioning systems, and it is even possible to react to and modify the experiments according to feedback from these devices. The front end of DAMARIS is user friendly and the usage is not difficult to learn. There is introductory documentation available [1]. The pulse programs are defined and written in Python which is, again, a very user friendly interpreted programming language. Using a full-fledged programming language with a huge amount of freely available libraries and a very active scientific community is a key advantage of using DAMARIS. The use of the scipy and numpy libraries (similar to Matlab) for numerical analysis and data treatment makes it possible, for example, to pre- or post-process data at single-shot or accumulation level using custom filters. Python modules can also be used to control auxiliary hardware such as oscilloscopes, magnet drift compensation controllers, microprocessors (Arduino, etc.), and step motor/piezo positioning systems, or to react to and modify experiments according to feedback from these devices.
@@ -53,6 +53,11 @@ Following is a short description of the options:
12. Compression settings 12. Compression settings
13. Authors, License and version numbers of used components 13. Authors, License and version numbers of used components
Temperature History
===================
DAMARIS automatically retrieves one temperature history for the complete experiment from the local ``tempproxy`` service at ``http://localhost:8000/v1/readings`` after the experiment finishes. The proxy controls the temperature device; no controller, serial device, or polling interval needs to be configured in DAMARIS. Successful readings are stored under the ``temperature`` DataPool key with elapsed seconds on the x-axis and the proxy's ``pv`` values unchanged on the y-axis, so units depend on the configured controller. If the proxy is unavailable or has no readings for the experiment, the history is omitted and the experiment's other data is still saved.
Experiment Script Experiment Script
================= =================
.. code-block:: python .. code-block:: python
+5 -5
View File
@@ -22,10 +22,10 @@ class TemperatureResult(Resultable, Drawable):
Attributes: Attributes:
x: List of timestamps in seconds. x: List of timestamps in seconds.
y: List of temperature readings in Celsius. y: List of temperature readings in the source units.
setpoint: Optional list of setpoint temperatures in Celsius. setpoint: Optional list of setpoint temperatures in the source units.
xlabel: Label for x-axis (default: "Time (s)"). xlabel: Label for x-axis (default: "Time (s)").
ylabel: Label for y-axis (default: "Temperature (C)"). ylabel: Label for y-axis (default: "Temperature").
""" """
def __init__(self, x = None, y = None, setpoint = 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) Resultable.__init__(self)
@@ -33,7 +33,7 @@ class TemperatureResult(Resultable, Drawable):
# Set default labels # Set default labels
self.xlabel = "Time (s)" self.xlabel = "Time (s)"
self.ylabel = "Temperature (C)" self.ylabel = "Temperature"
# Initialize data lists # Initialize data lists
self.x = [] if x is None else x self.x = [] if x is None else x
@@ -229,7 +229,7 @@ def read_from_hdf(hdf_node):
tr = TemperatureResult() tr = TemperatureResult()
tr.xlabel = getattr(hdf_node._v_attrs, "xlabel", "Time (s)") tr.xlabel = getattr(hdf_node._v_attrs, "xlabel", "Time (s)")
tr.ylabel = getattr(hdf_node._v_attrs, "ylabel", "Temperature (C)") tr.ylabel = getattr(hdf_node._v_attrs, "ylabel", "Temperature")
for r in hdf_node.iterrows(): for r in hdf_node.iterrows():
tr.x.append(r["x"]) tr.x.append(r["x"])
+12 -297
View File
@@ -96,7 +96,7 @@ from damaris.gui import BackendDriver
#from damaris.gui.gtkcodebuffer import CodeBuffer, SyntaxLoader #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 Drawable # this is a base class, it should be used...
from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult, TemperatureResult, FitResult from damaris.data import DataPool, Accumulation, ADC_Result, MeasurementResult, TemperatureResult, FitResult
# TemperatureMonitor imported lazily to avoid circular import issues from .TemperatureProxyClient import TemperatureProxyClient
# default, can be set to true from start script # default, can be set to true from start script
debug = False debug = False
@@ -196,9 +196,7 @@ class DamarisGUI:
#to stop queued experiments #to stop queued experiments
self.stop_experiment_flag = threading.Event() self.stop_experiment_flag = threading.Event()
# Temperature monitoring self.temperature_history_start = None
self.temp_monitor = None
self.temp_controller = None
exp_script = "" exp_script = ""
if exp_script_filename is not None and exp_script_filename != "": if exp_script_filename is not None and exp_script_filename != "":
@@ -686,12 +684,9 @@ class DamarisGUI:
self.data = self.si.data self.data = self.si.data
# run frontend and script engines # run frontend and script engines
self.monitor.observe_data_pool( self.data ) self.monitor.observe_data_pool( self.data )
# Start temperature monitoring if configured (before scripts start)
temp_start = time.time()
self.start_temperature_monitoring(actual_config)
logger.debug("Temperature monitoring startup took %.3fs", time.time() - temp_start)
scripts_start = time.time() scripts_start = time.time()
self.temperature_history_start = time.time()
self.si.runScripts( ) self.si.runScripts( )
logger.debug("Script execution startup took %.3fs", time.time() - scripts_start) logger.debug("Script execution startup took %.3fs", time.time() - scripts_start)
except Exception as e: except Exception as e:
@@ -869,8 +864,14 @@ 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] 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: if len( still_running ) == 0:
# Stop temperature monitoring when experiment finishes if self.temperature_history_start is not None:
self.stop_temperature_monitoring() temperature_history_start = self.temperature_history_start
self.temperature_history_start = None
self.data.pop("temperature", None)
temperature = TemperatureProxyClient().get_history(
temperature_history_start, time.time())
if temperature is not None:
self.data["temperature"] = temperature
if self.save_thread is None and self.dump_filename != "": if self.save_thread is None and self.dump_filename != "":
logger.info("All subprocesses ended, saving data pool") logger.info("All subprocesses ended, saving data pool")
# Disable run button during data saving to prevent UI confusion # Disable run button during data saving to prevent UI confusion
@@ -1193,97 +1194,8 @@ class DamarisGUI:
for r in still_running: for r in still_running:
r.quit_flag.set( ) r.quit_flag.set( )
# Stop temperature monitoring
self.stop_temperature_monitoring()
self.state = DamarisGUI.Stop_State 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:
logger.warning("Temperature monitoring: No device configured for Eurotherm")
return
self.temp_controller = create_controller(
"eurotherm", temp_device, baudrate=temp_baudrate
)
logger.info("Temperature monitoring started with Eurotherm on %s (interval: %ss)", temp_device, temp_interval)
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")
logger.info("Temperature monitoring started with simulated controller (interval: %ss)", temp_interval)
else:
logger.warning("Temperature monitoring: Unknown controller type: %s", 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:
logger.error("Failed to start temperature monitoring: %s", 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()
logger.info("Temperature monitoring stopped")
except Exception as e:
logger.error("Error stopping temperature monitoring: %s", e)
finally:
self.temp_monitor = None
if self.temp_controller is not None:
try:
self.temp_controller.close()
except Exception as e:
logger.error("Error closing temperature controller: %s", e)
finally:
self.temp_controller = None
def documentation_init( self ): def documentation_init( self ):
doc_index_candidates = ( doc_index_candidates = (
@@ -2464,75 +2376,6 @@ class ConfigTab:
backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0) backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0)
self.config_adc_bit_depth_hbox.show_all() self.config_adc_bit_depth_hbox.show_all()
# Temperature monitoring settings - single line layout
self.config_temp_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, 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.Box(orientation=gtk.Orientation.HORIZONTAL, 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.Box(orientation=gtk.Orientation.HORIZONTAL, 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.Box(orientation=gtk.Orientation.HORIZONTAL, 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.Box(orientation=gtk.Orientation.HORIZONTAL, 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 # insert version informations
components_text = """ components_text = """
operating system %(os)s operating system %(os)s
@@ -2639,28 +2482,10 @@ pygobject version %(pygobject)s
"data_pool_complib": complib, "data_pool_complib": complib,
"data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ), "data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ),
"script_font": self.config_script_font_button.get_font_name( ), "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"] ADC_Result.default_bit_depth = actual_config["adc_bit_depth"]
return actual_config 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 ): def set( self, config ):
if "start_backend" in config: if "start_backend" in config:
@@ -2693,40 +2518,6 @@ pygobject version %(pygobject)s
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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: if "data_pool_complib" in config:
# find combo-box entry and make it active... # find combo-box entry and make it active...
model = self.config_data_pool_complib.get_model( ) model = self.config_data_pool_complib.get_model( )
@@ -2743,82 +2534,6 @@ pygobject version %(pygobject)s
def on_adc_bit_depth_changed(self, widget): def on_adc_bit_depth_changed(self, widget):
ADC_Result.default_bit_depth = widget.get_value_as_int() 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,
)
dialog.add_button("Cancel", gtk.ResponseType.CANCEL)
dialog.add_button("Open", gtk.ResponseType.OK)
# Set default folder to /dev/serial/by-id/ if it exists
default_folder = "/dev/serial/by-id/"
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)):
treeiter = model.iter_nth_child(None, i)
if treeiter and model.get(treeiter, 0)[0] == text:
combobox.set_active(i)
return
def load_config_handler( self, widget ): def load_config_handler( self, widget ):
if self.system_default_filename: if self.system_default_filename:
self.load_config( self.system_default_filename ) self.load_config( self.system_default_filename )
-199
View File
@@ -1,199 +0,0 @@
# -*- 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
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()
self._consecutive_failures = 0
self._max_consecutive_failures = 10
self._last_error = None
self._connection_ok = True
# Initialize the result with metadata
self.result.xlabel = "Time (s)"
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 (dict item assignment is atomic under the GIL)
self.data_pool[self.data_key] = data_pool_result
# Reset consecutive failure counter on successful read
self._consecutive_failures = 0
self._last_error = None
self._connection_ok = True
except Exception as e:
self._consecutive_failures += 1
self._last_error = str(e)
if self._consecutive_failures <= 3:
print(f"TemperatureMonitor read error (attempt {self._consecutive_failures}): {e}")
elif self._consecutive_failures == 5:
print(f"TemperatureMonitor: repeated read errors, checking connection...")
if hasattr(self.controller, 'check_connection'):
self._connection_ok = self.controller.check_connection()
if not self._connection_ok:
print(f"TemperatureMonitor: WARNING - controller appears disconnected after {self._consecutive_failures} failures")
elif self._consecutive_failures >= self._max_consecutive_failures:
print(f"TemperatureMonitor: ERROR - {self._consecutive_failures} consecutive failures, controller may be disconnected")
else:
print(f"TemperatureMonitor error (failure #{self._consecutive_failures}): {e}")
# Sleep in small increments for responsive shutdown
sleep_remaining = self.interval
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()
+53
View File
@@ -0,0 +1,53 @@
"""Read experiment temperature history from the local tempproxy service.
Example:
client = TemperatureProxyClient()
temperature = client.get_history(start_epoch, end_epoch)
"""
import http.client
import json
import logging
from urllib.parse import urlencode
from urllib.request import urlopen
from damaris.data.Temperature import TemperatureResult
logger = logging.getLogger(__name__)
class TemperatureProxyClient:
"""Retrieve experiment-bounded readings from ``localhost:8000``."""
URL = "http://localhost:8000/v1/readings"
TIMEOUT = 5
def get_history(self, start_epoch: float, end_epoch: float) -> TemperatureResult | None:
"""Return temperature history relative to the experiment start, or ``None``.
Readings retain the proxy's ``pv`` units; an empty result or failed
request returns ``None``.
Example:
temperature = TemperatureProxyClient().get_history(1000.0, 1060.0)
"""
query = urlencode({"start": start_epoch, "end": end_epoch})
url = f"{self.URL}?{query}"
try:
with urlopen(url, timeout=self.TIMEOUT) as response:
payload = json.loads(response.read())
readings = payload["readings"]
if not isinstance(readings, list):
raise ValueError("response 'readings' must be a list")
if not readings:
return None
x = [float(reading["t"]) - start_epoch for reading in readings]
y = [float(reading["pv"]) for reading in readings]
return TemperatureResult(x=x, y=y)
except (OSError, http.client.HTTPException, ValueError, TypeError, KeyError) as error:
logger.warning("Unable to retrieve temperature history from tempproxy: %s", error)
return None
-168
View File
@@ -1,168 +0,0 @@
import serial
import re
import time
import operator
from functools import reduce
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='0011'
EOT = '\x04'
STX = '\x02'
ENQ = '\x05'
ETX = '\x03'
ACK = '\x06'
NAK = '\x15'
"""
Paramter 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)
"""
def checksum(message):
bcc = (reduce(operator.xor, list(map(ord,message))))
return chr(bcc)
class Eurotherm(TemperatureController):
def __init__(self, serial_device, baudrate = 19200):
self.device = standard_device
# timeout: 110 ms to get all answers.
self.s = serial.Serial(serial_device,
baudrate = baudrate,
bytesize=7,
parity='E',
stopbits=1,
timeout=0.11)
self._expect_len = 50
def send_read_param(self, param):
self.s.write(EOT + self.device + param + ENQ)
def read_param(self, param, retries=3, retry_delay=0.05):
for attempt in range(retries):
self.s.flushInput()
self.send_read_param(param)
answer = self.s.read(self._expect_len)
m = reply_pattern.search(answer)
if m is None:
answer += self.s.read(200)
m = reply_pattern.search(answer)
if m is not None:
self._expect_len = len(answer)
return m.group(1)
if attempt < retries - 1:
time.sleep(retry_delay)
print("received:", repr(answer))
return None
def write_param(self, mnemonic, data):
if len(mnemonic) > 2:
raise ValueError
bcc = checksum(mnemonic + data + ETX)
mes = EOT+self.device+STX+mnemonic+data+ETX+bcc
if DEBUG:
for i in mes:
print(i,hex(ord(i)))
self.s.flushInput()
self.s.write(mes)
answer = self.s.read(1)
# print "received:", repr(answer)
if answer == "":
# raise IOError("No answer from device")
return None
return answer[-1] == ACK
def get_current_temperature(self):
temp = self.read_param('PV')
if temp is None:
temp = "0"
return temp
def set_temperature(self, temperature):
return self.write_param('SL', str(temperature))
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 check_connection(self) -> bool:
"""Check if the Eurotherm device is connected and responsive."""
try:
result = self.read_param('PV')
return result is not None
except Exception:
return False
def close(self):
"""Close the serial connection."""
if hasattr(self, 's') and self.s is not None:
if self.s.is_open:
self.s.close()
self.s = None
if __name__ == '__main__':
import time
delta=5
date = time.strftime('%Y-%m-%d')
f = open('templog_%s'%date,'w')
f.write('# Start time: %s\n#delta t : %.1f s\n'%(time.asctime(), delta))
et = Eurotherm("/dev/ttyUSB0")
while True:
for i in range(120):
time.sleep(delta)
#t = time.strftime()
T = et.get_current_temperature()
l = '%f %s\n'%(time.time(),T)
print(time.asctime(), T)
f.write(l)
f.flush()
f.write('# MARK -- %s --\n'%(time.asctime()))
# Register Eurotherm with the factory
register_controller('eurotherm', Eurotherm)
-162
View File
@@ -1,162 +0,0 @@
import serial
import re
import time
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, retries=3, retry_delay=0.05):
"""Read a parameter using simple ASCII commands."""
for attempt in range(retries):
self.s.flushInput()
self.s.write(f"{param}?\n")
answer = self.s.readline().strip()
if answer and len(answer) > 1:
# Remove the first character (command echo) and return the value
return answer[1:]
if attempt < retries - 1:
time.sleep(retry_delay)
return None
def get_value(self, name):
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 1,{temperature}\n")
return True
def check_connection(self) -> bool:
"""Check if the Lakeshore device is connected and responsive."""
try:
result = self.read_param('KRDG')
return result is not None
except Exception:
return False
def close(self):
"""Close the serial connection."""
if hasattr(self, 's') and self.s is not None:
if self.s.is_open:
self.s.close()
self.s = None
# Register Lakeshore with the factory
register_controller('lakeshore', Lakeshore)
-161
View File
@@ -1,161 +0,0 @@
# -*- 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
def check_connection(self) -> bool:
"""
Check if the controller is connected and responsive.
Returns:
bool: True if the controller is connected and responsive,
False otherwise. Subclasses should override this method.
"""
return False
-126
View File
@@ -1,126 +0,0 @@
# -*- 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)
+93
View File
@@ -0,0 +1,93 @@
import io
import http.client
import json
import tempfile
import unittest
from urllib.error import HTTPError
from urllib.parse import parse_qs, urlparse
from unittest.mock import patch
import tables
from damaris.data.Temperature import read_from_hdf
from damaris.gui.TemperatureProxyClient import TemperatureProxyClient
class TestTemperatureProxyClient(unittest.TestCase):
def setUp(self):
self.client = TemperatureProxyClient()
@staticmethod
def response(payload):
return io.BytesIO(json.dumps(payload).encode("utf-8"))
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_get_history_uses_fixed_endpoint_and_converts_readings(self, mock_urlopen):
mock_urlopen.return_value = self.response({
"readings": [
{"t": 101.5, "pv": 293.15},
{"t": 104.0, "pv": 294.25},
]
})
result = self.client.get_history(100.0, 110.0)
args, kwargs = mock_urlopen.call_args
parsed_url = urlparse(args[0])
self.assertEqual(parsed_url.scheme, "http")
self.assertEqual(parsed_url.netloc, "localhost:8000")
self.assertEqual(parsed_url.path, "/v1/readings")
self.assertEqual(parse_qs(parsed_url.query), {"start": ["100.0"], "end": ["110.0"]})
self.assertEqual(kwargs, {"timeout": TemperatureProxyClient.TIMEOUT})
self.assertEqual(result.x, [1.5, 4.0])
self.assertEqual(result.y, [293.15, 294.25])
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_empty_readings_return_none(self, mock_urlopen):
mock_urlopen.return_value = self.response({"readings": []})
self.assertIsNone(self.client.get_history(100.0, 110.0))
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_invalid_responses_return_none_and_warn(self, mock_urlopen):
for payload in ({}, {"readings": "invalid"}, {"readings": [{"t": 101.0}]}):
with self.subTest(payload=payload):
mock_urlopen.return_value = self.response(payload)
with self.assertLogs("damaris.gui.TemperatureProxyClient", level="WARNING"):
self.assertIsNone(self.client.get_history(100.0, 110.0))
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_unavailable_proxy_http_errors_and_malformed_json_return_none(self, mock_urlopen):
failures = (
OSError("connection refused"),
TimeoutError("request timed out"),
http.client.BadStatusLine("not an HTTP response"),
HTTPError(TemperatureProxyClient.URL, 500, "server error", {}, None),
)
for failure in failures:
with self.subTest(failure=failure):
mock_urlopen.side_effect = failure
with self.assertLogs("damaris.gui.TemperatureProxyClient", level="WARNING"):
self.assertIsNone(self.client.get_history(100.0, 110.0))
mock_urlopen.side_effect = None
mock_urlopen.return_value = io.BytesIO(b"not json")
with self.assertLogs("damaris.gui.TemperatureProxyClient", level="WARNING"):
self.assertIsNone(self.client.get_history(100.0, 110.0))
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_result_round_trips_through_temperature_hdf_storage(self, mock_urlopen):
mock_urlopen.return_value = self.response({"readings": [{"t": 102.0, "pv": 295.0}]})
result = self.client.get_history(100.0, 110.0)
with tempfile.NamedTemporaryFile(suffix=".h5") as hdf_path:
with tables.open_file(hdf_path.name, mode="w") as hdf_file:
result.write_to_hdf(hdf_file, "/", "temperature", "Temperature")
restored = read_from_hdf(hdf_file.root.temperature)
self.assertEqual(restored.x, [2.0])
self.assertEqual(restored.y, [295.0])
if __name__ == "__main__":
unittest.main()