15 Commits
Author SHA1 Message Date
markusro dcf3ec0f2d Release v0.30
Build Debian Packages / build (bookworm, debian12) (push) Successful in 19m5s
Build Debian Packages / build (trixie, debian13) (push) Successful in 21m11s
2026-10-21 00:52:30 +02:00
markusro 03e1155ae1 ignoring kilo and podman folders 2026-10-21 00:50:25 +02:00
markusro dda8ae7df0 script for release added, pyrefly checks are fixed for now 2026-10-21 00:48:06 +02:00
markusro 428af30c30 expanded features in res_test.py 2026-10-21 00:21:39 +02:00
markusroandJunie f9fa03e312 Fix Sphinx documentation warnings
Co-authored-by: Junie <junie@jetbrains.com>
2026-10-21 00:18:58 +02:00
markusro 0e796a800b Replace temperature monitoring with proxy history 2026-10-21 00:08:26 +02:00
markusro bd060dc4d0 create base class MeasurementData for ADC_Result and Accumulation 2026-10-20 23:21:43 +02:00
markusro 5a18e48c2d New functions: get_mean and get_phase, added better documentation 2026-10-20 22:48:30 +02:00
markusro 9111e546e0 fixes and new tag 2026-10-19 23:22:11 +02:00
markusro 7164531351 add toolbar-free mouse panning and zooming to the plot
Build Debian Packages / build (bookworm, debian12) (push) Successful in 11m52s
Build Debian Packages / build (trixie, debian13) (push) Successful in 12m3s
Connect matplotlib event handlers on the plot canvas so the display
can be navigated without engaging the toolbar modes: left button
drag pans the view, the mouse wheel zooms both axes around the
pointer, and right button drag zooms single axes (horizontal motion
zooms x, vertical motion zooms y, like the right button in the
toolbar pan mode). Linear (including inverted ppm) and logarithmic
axes are handled, the latter in log space. While a toolbar pan/zoom
mode is active the handlers stand back so the toolbar behavior is
unchanged. Documented in doc/manual.rst.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:43 +02:00
markusro 1bed381639 add toolbar buttons copying the plot as PNG and SVG to the clipboard
Two new buttons below the plot copy the current figure to the
clipboard. The PNG copy renders off-screen on a temporary Agg canvas
at a fixed 12:8 inches and 800 px width, independent of the window
size, and restores figure canvas, size and dpi afterwards so the
on-screen plot is untouched. The RGBA buffer is wrapped in GLib.Bytes
for GdkPixbuf.Pixbuf.new_from_bytes, which current PyGObject requires
(fixes the 'Expected GLib.Bytes, but got bytes' error). The SVG copy
keeps using savefig into a text buffer; both report through logger.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:34 +02:00
markusro 0135f9b7a3 route the unknown-data-type message through the logger
save_display_data_as_text printed via the removed log() helper;
use logger.warning instead so the message lands in the log window.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:26 +02:00
markusro 0135504de2 remove the canvas context menu
The middle-click context menu (crosshair toggle, copy as PNG/SVG)
duplicated functionality that is now reachable elsewhere: the
crosshair is toggled by its own toolbar button and the PNG/SVG
clipboard copies get dedicated toolbar buttons. Removing it frees the
middle mouse button on the plot canvas.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:22 +02:00
markusro d539a27423 ignore duplicate quit events when the window is closed twice
quit_event ran its cleanup path again when a second delete-event
arrived after the state had already switched to Quit_State, hanging
the application on shutdown. Return early instead.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:16:14 +02:00
markusro 34eb7b2728 cap matplotlib canvas at logical resolution for fast HiDPI rendering
The stock GTK3Agg canvas renders one buffer pixel per physical pixel,
so on a scaled (HiDPI) screen every full redraw rasterizes four times
the pixel count, which makes the interactive plot sluggish. Override
_update_device_pixel_ratio in BlitSafeCanvas to pin the device pixel
ratio at 1: the plot renders at logical resolution and the window
system scales the output up. Measured ~2.3x faster full redraws on a
2x-scaled screen; non-scaled screens are unaffected.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-10-19 23:15:30 +02:00
29 changed files with 822 additions and 1356 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
{
"linters": {
"sample": {
"type": "pep8",
"include": "(\\.py$)",
"exclude": "(^third-party/)"
}
}
}
+13 -1
View File
@@ -22,13 +22,25 @@ jobs:
- name: Install Node.js and git
run: |
apt-get update
apt-get -y install nodejs git lsb-release jq
apt-get -y install nodejs git lsb-release jq devscripts
- name: Checkout repository
uses: actions/checkout@v3
with:
github-server-url: https://gitea.pkm.physik.tu-darmstadt.de
- name: Set package version from release tag
if: startsWith(gitea.ref, 'refs/tags/v')
env:
TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
if [ "$(dpkg-parsechangelog --show-field Version)" != "$VERSION" ]; then
dch --newversion "$VERSION" --distribution stable --force-distribution --noquery "Release $TAG"
fi
sed -i -E "s/^(__version__ = \")([^\"]+)(\")$/\1${VERSION}\3/" src/damaris/__init__.py
- name: Install build dependencies
run: bash requirements.debian
+2
View File
@@ -11,6 +11,8 @@ debian/files
.junie
debian/debhelper-build-stamp
debian/.debhelper
podman
.kilo
# Sphinx documentation
doc/_build/
+2 -2
View File
@@ -16,5 +16,5 @@ This is the front end part of GUI for controlling NMR spectrometers. On "Start",
10. The gitea workflow can be found at .gitea/workflows/debian-build.yml
11. Do not edit anything in "build", it is regenerated
12. Use sphinx for documentation, autodoc and autosummary is used for references of all classes, functions and modules.
13. If a new function/method is created document a short usage in doc/api_reference.rst and a longer explanation in doc/manual.rst
14. Do not use the Makefile in ./doc/, Doxygen is not used anymore.
13. If a new function/method is created document a short usage and an example in the doc string. Add a longer explanation in doc/manual.rst in the command list. Use Sphinx autodoc and autosummary grammar to describe it. Use "timesignal" as ADC_Result object and "accu" as Accumulation object.
14. Doxygen is not used anymore!
+16
View File
@@ -1,3 +1,19 @@
python3-damaris (0.30) stable; urgency=medium
* replaced temperature with REST source the data from a temperature proxy
* pyrefly checks fixed for now
* docs updated
* created base class for ADC_Result and Accumulation
-- Markus Rosenstihl <markus.rosenstihl@pkm.physik.tu-darmstadt.de> Wed, 21 Oct 2026 00:51:17 +0200
python3-damaris (0.29) stable; urgency=medium
* fixed PNG/SVG buttons
* speed up rendereing in HiDPI screens
-- Markus Rosenstihl <markus.rosenstihl@pkm.tu-darmstadt.de> Mon, 19 Oct 2026 23:20:04 +0200
python3-damaris (0.25) stable; urgency=medium
* fixed non working stop button
+1 -15
View File
@@ -5,16 +5,6 @@ API Reference
This page provides an auto-generated reference of all DAMARIS classes, functions, and modules.
It is built from docstrings using Sphinx autodoc and autosummary.
.. toctree::
:maxdepth: 2
:caption: Modules:
damaris/data
damaris/experiments
damaris/gui
damaris/tools
Data Module
===========
@@ -70,7 +60,7 @@ The GUI module contains the graphical interface, backend driver, and experiment/
damaris.gui.ExperimentWriter
damaris.gui.ResultHandling
damaris.gui.ResultReader
damaris.gui.TemperatureMonitor
damaris.gui.TemperatureProxyClient
damaris.gui.logging_utils
A short usage example for the font helper of the GUI module (the non-deprecated
@@ -97,11 +87,7 @@ The tools module contains hardware driver interfaces and utility functions.
:toctree: _autosummary
:recursive:
damaris.tools.eurotherm
damaris.tools.flow_control
damaris.tools.goniometer
damaris.tools.lakeshore
damaris.tools.ranges
damaris.tools.signal
damaris.tools.temperature
damaris.tools.temperature_simulator
+2 -2
View File
@@ -33,7 +33,7 @@ bibtex_bibstyles = ['numeric']
bibtex_citestyle = 'numeric'
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'damaris/**']
language = 'en'
@@ -42,4 +42,4 @@ language = 'en'
#html_theme = 'alabaster'
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']
html_static_path = []
+42 -4
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.
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
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
=================
.. code-block:: python
@@ -100,6 +105,8 @@ There are now two possibilities to export the figure:
1. Saving the plot as a figure (EPS, PNG, JPEG and more) by pressing the save button in the toolbar below the plot area
2. Saving the data of the plot as CSV file (comma separated values) by pressing the button on the right side of the toolbar under the plot
The plot can be navigated directly with the mouse: dragging with the left mouse button pans the view and the mouse wheel zooms in and out around the pointer position. Holding the right mouse button and dragging horizontally zooms only the x axis, dragging vertically zooms only the y axis (dragging right or up zooms in), just like the right button in the toolbar pan mode. This works on linear and logarithmic axes. The pan and zoom buttons in the toolbar below the plot are still available; while one of these toolbar modes is active, the direct mouse panning and right-button zooming are suspended so the toolbar behavior is unchanged. Note that with the autoscaling option enabled, the view is reset to the full data range whenever a new result arrives.
Help Menu
=========
@@ -456,9 +463,9 @@ Data Processing Outside DAMARIS
For post-processing of the data, several options are available, depending on the format of the data. Most programs can handle ASCII files (gnuplot, Origin, fitsh, etc.), so this is the more flexible format. HDF can be read by pyTables, Matlab, Octave, R, Mathematica and more.
**************************
Experiment Script Commands
==========================
**************************
Following is a list of available commands in an experiment script.
@@ -661,8 +668,9 @@ Following are convenient functions for use in loops.
for tp in combine_ranges(mylinrange, mylogrange):
yield inversion_recovery(tau=t)
**********************
Result Script Commands
======================
**********************
Following is a list of available commands in a result script. These are methods to be applied to a ADC_Result and Accumulation object. An accumulation object needs to be created before any methods can be applied.
@@ -756,6 +764,36 @@ Example:
- **get_ymax()**
Returns maximum of y
- **get_ymean(start=None, stop=None)**
Returns the mean value of every channel. If *start* and *stop* are given,
they select a time interval in seconds. The calculation uses raw values
for **timesignal** and the public mean values for **accu**.
Example:
.. code-block:: python
means = timesignal.get_ymean(start=1e-6, stop=4e-6)
means = accu.get_ymean()
- **get_phase(pos=None)**
Returns the phase in degrees from the first two channels at the first
sample or at time *pos* in seconds.
Example:
.. code-block:: python
phase = timesignal.get_phase(pos=1e-6)
- **get_ymin_channels()/get_ymax_channels()**
Return NumPy arrays containing the minimum or maximum for each channel.
**get_ymin()** and **get_ymax()** return the corresponding global extrema.
- **get_yrms()/get_ystd()**
Return NumPy arrays containing the RMS or population standard deviation
for each channel.
.. bibliography::
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 VERSION (for example: 0.30)"
}
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
usage
exit 0
fi
if [[ $# -ne 1 || ! $1 =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
usage >&2
exit 2
fi
version=$1
tag="v$version"
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
if [[ -n $(git status --porcelain) ]]; then
echo "Release requires a clean working tree." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "Tag $tag already exists." >&2
exit 1
fi
command -v dch >/dev/null || { echo "Install devscripts to use dch." >&2; exit 1; }
command -v dpkg-parsechangelog >/dev/null || { echo "Install dpkg-dev to use dpkg-parsechangelog." >&2; exit 1; }
current_version=$(dpkg-parsechangelog --show-field Version)
if [[ $current_version == "$version" ]]; then
echo "debian/changelog already has version $version." >&2
exit 1
fi
dch --newversion "$version" --distribution stable --force-distribution
sed -i "s/^__version__ = .*/__version__ = \"$version\"/" src/damaris/__init__.py
if [[ $(dpkg-parsechangelog --show-field Version) != "$version" ]] ||
[[ $(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/damaris/__init__.py) != "$version" ]]; then
echo "Release version did not update consistently; inspect the changelog and package version." >&2
exit 1
fi
echo "About to commit the version bump and create tag $tag."
read -r -p "Continue? [y/N] " answer
if [[ ! $answer =~ ^[Yy]$ ]]; then
echo "Release preparation stopped; version and changelog edits were kept."
exit 1
fi
git add debian/changelog src/damaris/__init__.py
git commit -m "Release $tag"
git tag -a "$tag" -m "Release $tag"
echo "Created local release tag $tag. Push the commit and tag to start the package workflow."
echo " git push origin HEAD"
echo " git push origin $tag"
+1 -1
View File
@@ -15,7 +15,7 @@
## module contents
#
__version__ = "0.25"
__version__ = "0.30"
__all__=["experiments", "data", "gui"]
import sys
+20 -27
View File
@@ -6,18 +6,20 @@ from .Resultable import Resultable
from .Drawable import Drawable
from .Signalpath import Signalpath
from .DamarisFFT import DamarisFFT
from .MeasurementData import MeasurementData
import threading
import numpy
import sys
import datetime
import tables
from typing import IO
from typing import IO, Any, Literal, cast
from scipy.signal import filtfilt, remez
_XArray = numpy.ndarray | list
_YChannels = list[numpy.ndarray]
_IndexList = list[tuple[int, int]]
_Description = dict[str, str]
_CompressionLibrary = Literal["blosc", "blosc2", "bzip2", "lzo", "zlib"]
#############################################################################
# #
# Name: Class ADC_Result #
@@ -27,7 +29,7 @@ _Description = dict[str, str]
# #
#############################################################################
class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
class ADC_Result(MeasurementData, Resultable, Drawable, DamarisFFT, Signalpath):
default_bit_depth = 14
"""
Represents the result of an ADC, encapsulating data and metadata
@@ -376,13 +378,14 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None:
accu_group=hdffile.create_group(where=where,name=name,title=title)
accu_group._v_attrs.damaris_type="ADC_Result"
attrs = cast(Any, accu_group._v_attrs)
attrs.damaris_type="ADC_Result"
if self.contains_data():
self.lock.acquire()
try:
# save time stamps
if "job_date" in dir(self) and self.job_date is not None:
accu_group._v_attrs.time="%04d%02d%02d %02d:%02d:%02d.%03d"%(self.job_date.year,
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,
@@ -392,15 +395,17 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
if self.description is not None:
for (key,value) in self.description.items():
accu_group._v_attrs.__setattr__("description_"+key,str(value))
accu_group._v_attrs.__setattr__("sampling_rate",self.sampling_rate)
attrs.__setattr__("description_"+key,str(value))
attrs.__setattr__("sampling_rate",self.sampling_rate)
# save interval information
filter=None
if complib is not None:
if complevel is None:
complevel=9
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
filter=tables.Filters(complevel=complevel,
complib=cast(_CompressionLibrary, complib),
shuffle=True)
index_table=hdffile.create_table(where=accu_group,
name="indices",
@@ -413,7 +418,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
expectedrows=len(self.index))
index_table.flavor="numpy"
# save channel data
new_row=index_table.row
new_row = cast(Any, index_table.row)
for i in range(len(self.index)):
new_row["start"]=self.index[i][0]
new_row["dwelltime"]=1.0/self.sampling_rate
@@ -435,20 +440,7 @@ class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
# save data
time_slice_data=None
if filter is not None:
chunkshape = numpy.shape(timedata)
if len(chunkshape) <= 1:
chunkshape = (min(chunkshape[0],1024*8),)
else:
chunkshape = (min(chunkshape[0],1024*8), chunkshape[1])
if tables.__version__[0]=="1":
time_slice_data=hdffile.create_carray(accu_group,
name="adc_data",
shape=timedata.shape,
atom=tables.Int16Atom(shape=chunkshape,
flavor="numpy"),
filters=filter,
title="adc data")
else:
chunkshape = (min(timedata.shape[0], 1024 * 8), timedata.shape[1])
time_slice_data=hdffile.create_carray(accu_group,
name="adc_data",
shape=timedata.shape,
@@ -683,7 +675,8 @@ def read_from_hdf(hdf_node: object) -> ADC_Result | None:
if not isinstance(hdf_node, tables.Group):
return None
if hdf_node._v_attrs.damaris_type!="ADC_Result":
attrs = cast(Any, hdf_node._v_attrs)
if attrs.damaris_type!="ADC_Result":
return None
if not (hdf_node.__contains__("indices") and hdf_node.__contains__("adc_data")):
@@ -695,12 +688,12 @@ def read_from_hdf(hdf_node: object) -> ADC_Result | None:
adc.set_title(hdf_node._v_title)
# populate description dictionary
adc.description={}
for attrname in hdf_node._v_attrs._v_attrnamesuser:
for attrname in attrs._v_attrnamesuser:
if attrname.startswith("description_"):
adc.description[attrname[12:]]=hdf_node._v_attrs.__getattr__(attrname)
adc.description[attrname[12:]]=attrs.__getattr__(attrname)
if "time" in dir(hdf_node._v_attrs):
timestring=hdf_node._v_attrs.__getattr__("time")
if "time" in dir(attrs):
timestring=attrs.__getattr__("time")
adc.job_date=datetime.datetime(int(timestring[:4]), # year
int(timestring[4:6]), # month
int(timestring[6:8]), # day
+96 -63
View File
@@ -16,6 +16,7 @@ from .Drawable import Drawable
from .DamarisFFT import DamarisFFT
from .Signalpath import Signalpath
from .ADC_Result import ADC_Result
from .MeasurementData import MeasurementData
import sys
import threading
@@ -25,15 +26,16 @@ import datetime # added by Oleg Petrov
import ctypes # added by Oleg Petrov
import struct # added by Oleg Petrov
import os # added by Oleg Petrov
from typing import IO
from typing import IO, Any, Literal, cast
_XArray = numpy.ndarray | list
_YChannels = list[numpy.ndarray]
_IndexList = list[tuple[int, int]]
_Description = dict[str, str]
_CompressionLibrary = Literal["blosc", "blosc2", "bzip2", "lzo", "zlib"]
class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath):
"""
Represents an accumulation of sampled data with various attributes and methods
for data manipulation, analysis, and exporting.
@@ -221,10 +223,10 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
return tmp_y
def get_ymin(self) -> float|int:
if not self.contains_data():
return 0
tmp_min = []
self.lock.acquire()
for i in range(self.get_number_of_channels()):
@@ -257,12 +259,24 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
return max(tmp_max)
def get_job_id(self) -> int | None:
"""
Returns the job identifier associated with this accumulation.
"""
return self.job_id # modified by Oleg Petrov
def write_to_csv(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ") -> None:
"""
writes the data to a file.
destination can be a filehandle or a filename, default sys.stdout
Write the accumulation data to a whitespace-delimited text file with comment headers.
The file starts with comment lines containing accumulation metadata and a header line
with channel names. Data rows contain time and channel means, optionally with errors.
Example:
accu = Accumulation(...)
accu.write_to_csv('data.txt', delimiter=' ')
:param destination: file handle or filename to write to. Defaults to sys.stdout.
:param delimiter: delimiter to use between columns. Defaults to " ".
"""
if isinstance(destination, str):
with open(destination, "w") as f:
@@ -294,7 +308,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
for i in range(len(xdata)):
dest.write("%e"%xdata[i])
for j in range(ch_no):
if self.use_error:
if self.use_error and yerr is not None:
dest.write("%s%e%s%e"%(delimiter, ydata[j][i], delimiter, yerr[j][i]))
else:
dest.write("%s%e"%(delimiter,ydata[j][i]))
@@ -305,9 +319,18 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# ------------- added by Oleg Petrov, 14 Feb 2012 ----------------------
def write_to_simpson(self, destination: str | IO[str] = sys.stdout, delimiter: str = " ", frequency: float = 100e6) -> None:
"""
writes the data to a text file or sys.stdout in Simpson format,
for further processing with the NMRnotebook software;
destination can be a file or a filename
Write the accumulation data to a text file in Simpson format for NMRnotebook.
The output contains a simple header with NP, SW, REF and TYPE=FID followed by
the data block.
Example:
accu = Accumulation(...)
accu.write_to_simpson('data.simp', frequency=100e6)
:param destination: file handle or filename. Defaults to sys.stdout.
:param delimiter: column delimiter for data rows. Defaults to space.
:param frequency: reference frequency. Defaults to 100e6.
"""
if isinstance(destination, str):
with open(destination, "w") as f:
@@ -336,15 +359,27 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
self.lock.release()
# ------------- added by Oleg Petrov, 10 Sep 2013 -----------------------
def write_to_tecmag(self, destination = sys.stdout, nrecords: int = 1,\
def write_to_tecmag(self, destination: str | IO[bytes] = sys.stdout.buffer, nrecords: int = 1,\
frequency: float = 100.,\
last_delay: float = 1.,\
receiver_phase: float = 0.,\
nucleus: str = '1H') -> None:
"""
writes the data to a binary file in TecMag format;
destination can be a file object or a filename;
nrecords determines an indirect dimension in 2D experiments;
Write the accumulation data to a binary TecMag file.
The file is written in TNT1.005 format compatible with TecMag processing.
Requires job_id and time_period to be set.
Example:
accu = Accumulation(...)
accu.write_to_tecmag('data.tnt', nrecords=1, frequency=100.)
:param destination: file handle or filename.
:param nrecords: number of indirect dimension records for 2D experiments.
:param frequency: spectrometer frequency.
:param last_delay: last delay value.
:param receiver_phase: receiver phase.
:param nucleus: nucleus label, defaults to '1H'.
"""
#TODO: Function is most likely broken in Python 3 because Strings are now unicode and binary files cannot write unicode
@@ -354,8 +389,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
else:
record = (self.job_id/self.n)%nrecords + 1
the_destination=destination
if type(destination) in (str,):
the_destination: IO[bytes] | None = None
if isinstance(destination, str):
if record == 1 and os.path.exists(destination):
os.rename(destination, os.path.dirname(destination)+'/~'+os.path.basename(destination))
@@ -386,7 +421,10 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
data = data.flatten()
if record == 1:
if isinstance(destination, str):
the_destination = open(destination, "wb")
else:
the_destination = destination
# allocate space for all records in advance:
buff = ctypes.create_string_buffer(1056+data_offset+2068)
@@ -447,56 +485,69 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
struct.pack_into('4s', buff, 1056+data_offset+2060, 'PSEQ') # 'PSEQ' tag 658476
struct.pack_into('?', buff, 1056+data_offset+2064, False) # BOOLean 658480
the_destination.write(buff)
the_destination.write(buff.raw)
else:
if isinstance(destination, str):
the_destination = open(destination, "rb+")
else:
the_destination = destination
buff = ctypes.create_string_buffer(4*2*npts[0])
struct.pack_into('%sf' % (2*npts[0]), buff, 0, *data)
the_destination.seek(1056+4*2*npts[0]*(record-1))
the_destination.write(buff)
the_destination.write(buff.raw)
the_destination = None
ydata=None
finally:
if isinstance(destination, str) and the_destination is not None:
the_destination.close()
self.lock.release()
# -----------------------------------------------------------------------
def write_to_hdf(self, hdffile: tables.File, where: str, name: str, title: str, complib: str | None = None, complevel: int | None = None) -> None:
"""
Write the accumulation data to an HDF5 file.
Write the accumulation data to an HDF5 file using PyTables.
Parameters:
- hdffile: The HDF5 file object to write to.
- where: The location within the HDF5 file where the data should be stored.
- name: The name of the dataset within the HDF5 file.
- title: The title or description of the dataset.
- complib: Compression library to use for the dataset.
- complevel: Compression level for the dataset.
Creates a group with indices, job_ids and accu_data arrays. Metadata and
sampling rate are stored as attributes.
Example:
accu = Accumulation(...)
accu.write_to_hdf(h5file, '/data', 'accu1', 'Accumulation')
:param hdffile: PyTables File object to write to.
:param where: location within the HDF5 file.
:param name: name of the dataset group.
:param title: title/description of the dataset.
:param complib: compression library, e.g. 'zlib'.
:param complevel: compression level.
"""
accu_group=hdffile.create_group(where=where,name=name,title=title)
accu_group._v_attrs.damaris_type="Accumulation"
attrs = cast(Any, accu_group._v_attrs)
attrs.damaris_type="Accumulation"
if self.contains_data():
self.lock.acquire()
try:
# save time stamps
if self.time_period is not None and len(self.time_period)>0:
accu_group._v_attrs.earliest_time = self.time_period[0].isoformat(sep=' ', timespec='milliseconds')
accu_group._v_attrs.oldest_time = self.time_period[1].isoformat(sep=' ', timespec='milliseconds')
attrs.earliest_time = self.time_period[0].isoformat(sep=' ', timespec='milliseconds')
attrs.oldest_time = self.time_period[1].isoformat(sep=' ', timespec='milliseconds')
if self.common_descriptions is not None:
for (key,value) in self.common_descriptions.items():
accu_group._v_attrs.__setattr__("description_"+key,str(value))
accu_group._v_attrs.__setattr__("sampling_rate",self.sampling_rate)
attrs.__setattr__("description_"+key,str(value))
attrs.__setattr__("sampling_rate",self.sampling_rate)
filter=None
if complib is not None:
if complevel is None:
complevel=3
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
filter=tables.Filters(complevel=complevel,
complib=cast(_CompressionLibrary, complib),
shuffle=True)
# save interval information
# start save index_table
@@ -513,7 +564,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
expectedrows=len(self.index))
index_table.flavor="numpy"
# save interval data
new_row=index_table.row
new_row = cast(Any, index_table.row)
for i in range(len(self.index)):
new_row["start"]=self.index[i][0]
new_row["dwelltime"]=1.0/self.sampling_rate
@@ -534,7 +585,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
title="job_ids used for accumulation",
filters=filter,
expectedrows=len(self.job_ids))
new_row=jobid_table.row
new_row = cast(Any, jobid_table.row)
for i,job_id in enumerate(self.job_ids):
new_row["job_date"] = self.job_ids[job_id].isoformat(timespec='milliseconds')
new_row["job_id"]=job_id
@@ -555,20 +606,7 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# save data
time_slice_data=None
if filter is not None:
chunkshape=timedata.shape
if len(chunkshape) <= 1:
chunkshape = (min(chunkshape[0],1024*8),)
else:
chunkshape = (min(chunkshape[0],1024*8), chunkshape[1])
if tables.__version__[0]=="1":
time_slice_data=hdffile.create_carray(accu_group,
name="accu_data",
shape=timedata.shape,
atom=tables.Float32Atom(shape=chunkshape,
flavor="numpy"),
filters=filter,
title="accu data")
else:
chunkshape = (min(timedata.shape[0], 1024 * 8), timedata.shape[1])
time_slice_data=hdffile.create_carray(accu_group,
name="accu_data",
shape=timedata.shape,
@@ -594,13 +632,6 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
# External interfaces ------------------------------------------------------------------
# Overloaded operators -----------------------------------------------------------------
def __len__(self) -> int:
"""
return number of samples per channel, 0 if empty
"""
if len(self.y)>0:
return len(self.y[0])
return 0
def __repr__(self) -> str:
"""Redefining repr(Accumulation)"""
@@ -1078,7 +1109,8 @@ class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
self.set_title(self.__title_pattern % self.n)
if hasattr(other, "is_clipped"):
self.is_clipped = other.is_clipped
self.common_descriptions=other.common_descriptions.copy()
self.common_descriptions = (other.common_descriptions.copy()
if other.common_descriptions is not None else None)
self.time_period=other.time_period[:] if other.time_period is not None else None
self.job_id = other.job_id # added by Oleg Petrov
self.job_ids.update(other.job_ids)
@@ -1180,7 +1212,8 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
if not isinstance(hdf_node, tables.Group):
return None
if hdf_node._v_attrs.damaris_type!="Accumulation":
attrs = cast(Any, hdf_node._v_attrs)
if attrs.damaris_type!="Accumulation":
return None
if not (hdf_node.__contains__("indices") and hdf_node.__contains__("accu_data")):
@@ -1191,13 +1224,13 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
# populate description dictionary
accu.common_descriptions={}
for attrname in hdf_node._v_attrs._v_attrnamesuser:
for attrname in attrs._v_attrnamesuser:
if attrname.startswith("description_"):
accu.common_descriptions[attrname[12:]]=hdf_node._v_attrs.__getattr__(attrname)
accu.common_descriptions[attrname[12:]]=attrs.__getattr__(attrname)
earliest_time=None
if "earliest_time" in dir(hdf_node._v_attrs):
timestring=hdf_node._v_attrs.__getattr__("earliest_time")
if "earliest_time" in dir(attrs):
timestring=attrs.__getattr__("earliest_time")
# Handle both formats: "20260705 19:13:59.162" and "2026-07-05 19:13:59.162"
if '-' in timestring:
# New format: "2026-07-05 19:13:59.162"
@@ -1228,8 +1261,8 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
)
oldest_time=None
if "oldest_time" in dir(hdf_node._v_attrs):
timestring=hdf_node._v_attrs.__getattr__("oldest_time")
if "oldest_time" in dir(attrs):
timestring=attrs.__getattr__("oldest_time")
# Handle both formats: "20260705 19:13:59.162" and "2026-07-05 19:13:59.162"
if '-' in timestring:
# New format: "2026-07-05 19:13:59.162"
+20 -36
View File
@@ -15,7 +15,7 @@ class DataPool(collections.abc.MutableMapping):
# supports translation from dictionary keys to pytables hdf node names
# taken from: Python Ref Manual Section 2.3: Identifiers and keywords
# things are always prefixed by "dir_" or "dict_"
# things are always prefixed by ``dir_`` or ``dict_``
"""
translation_table=""
for i in range(256):
@@ -118,44 +118,28 @@ class DataPool(collections.abc.MutableMapping):
This method serializes the entire content of the data pool into an HDF5 file, creating
appropriate groups and arrays based on the keys and values stored in the data pool.
Nested storage paths are supported, and group or array names are
converted into valid HDF5 names. Paths are prepended with "dir_", results with "dict_".
If duplicate names arise, extensions (like `_001`, `_002`)
converted into valid HDF5 names. Paths are prepended with ``dir_``, results with ``dict_``.
If duplicate names arise, extensions (like ``_001``, ``_002``)
are appended to ensure uniqueness.
Parameters:
hdffile : str | bytes | tables.File
Path to the HDF5 file or an already open `tables.File` object where the
data should be written. Strings or bytes are interpreted as file paths.
:param hdffile: HDF5 file path or an open ``tables.File`` object.
:type hdffile: str, bytes, or tables.File
:param where: Root HDF5 group path under which data is stored.
:type where: str
:param name: Name of the root HDF5 group. Defaults to ``data_pool``.
:type name: str
:param complib: Compression library; ``None`` disables compression.
:type complib: str or None
:param complevel: Compression level; unused when ``complib`` is ``None``.
:type complevel: int or None
:param incremental: If true, write only keys changed since the last write.
:type incremental: bool
:param write_interval: Time interval in seconds between periodic writes.
:type write_interval: int
:raises Exception: If ``hdffile`` is neither a string nor a ``tables.File`` object.
where : str, optional
The root HDF5 group path under which the data pool will be stored.
Defaults to "/".
name : str, optional
The name of the root HDF5 group under which the data pool is saved.
Defaults to "data_pool".
complib : str, optional
Compression library to be used when creating HDF5 arrays. Optional, defaults
to None, meaning no compression is applied.
complevel : int, optional
Compression level to use when applying compression. Has no effect if `complib`
is set to None.
incremental : bool, optional
If True, only write keys that have been modified since the last write (dirty keys).
If False, write all keys. Defaults to False.
write_interval : int, optional
Time interval in seconds between periodic writes.
Defaults to 0.
Raises:
Exception
If `hdffile` is neither a string nor a valid `tables.File` object.
Notes:
Notes
-----
- This method assumes that the content of the data pool does not change during the
write operation.
- Keys in the data pool starting with "__" (double underscores) are skipped.
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: iso-8859-1 -*-
from __future__ import annotations
import numpy
class MeasurementData:
"""Common channel-series access and numerical analysis for measurements.
Concrete result classes provide storage and ``get_ydata``; all calculations
in this class therefore use the public channel values rather than their
internal representation.
"""
def contains_data(self) -> bool:
"""Return whether the measurement contains samples."""
raise NotImplementedError
def get_ydata(self, channel: int = 0) -> numpy.ndarray:
"""Return public values for one channel."""
return self.y[channel]
def get_number_of_channels(self) -> int:
"""Return the number of data channels."""
return len(self.y)
def get_sampling_rate(self) -> float:
"""Return the sampling frequency in hertz."""
return self.sampling_rate
def __len__(self) -> int:
"""Return the number of samples per channel.
Example:
samples = len(timesignal)
"""
if not self.get_number_of_channels():
return 0
return len(self.get_ydata(0))
def get_ymean(self, start: float | None = None,
stop: float | None = None) -> numpy.ndarray:
"""Return the mean of every channel over a time interval.
Example:
means = timesignal.get_ymean(start=1e-6, stop=4e-6)
"""
if not self.contains_data():
return numpy.array([])
if (start is None) != (stop is None):
raise ValueError("start and stop must be specified together")
if start is None:
values = [self.get_ydata(channel).mean()
for channel in range(self.get_number_of_channels())]
else:
istart = int(self.get_sampling_rate() * start)
istop = int(self.get_sampling_rate() * stop)
values = [self.get_ydata(channel)[istart:istop].mean()
for channel in range(self.get_number_of_channels())]
return numpy.asarray(values)
def get_phase(self, pos: float | None = None) -> float:
"""Return the phase in degrees from the first two channels.
Example:
phase = timesignal.get_phase(pos=1e-6)
"""
if self.get_number_of_channels() < 2:
raise ValueError("phase requires at least two channels")
ipos = 0 if pos is None else int(self.get_sampling_rate() * pos)
return float(numpy.degrees(numpy.arctan2(
self.get_ydata(1)[ipos], self.get_ydata(0)[ipos])))
def get_ymin_channels(self) -> numpy.ndarray:
"""Return the minimum value of each channel.
Example:
minima = accu.get_ymin_channels()
"""
if not self.contains_data():
return numpy.array([])
return numpy.asarray([self.get_ydata(i).min()
for i in range(self.get_number_of_channels())])
def get_ymax_channels(self) -> numpy.ndarray:
"""Return the maximum value of each channel.
Example:
maxima = accu.get_ymax_channels()
"""
if not self.contains_data():
return numpy.array([])
return numpy.asarray([self.get_ydata(i).max()
for i in range(self.get_number_of_channels())])
def get_ymin(self) -> float | int:
"""Return the global minimum across all channels."""
values = self.get_ymin_channels()
return 0 if len(values) == 0 else values.min()
def get_ymax(self) -> float | int:
"""Return the global maximum across all channels."""
values = self.get_ymax_channels()
return 0 if len(values) == 0 else values.max()
def get_yrms(self) -> numpy.ndarray:
"""Return the root-mean-square value of each channel.
Example:
rms = timesignal.get_yrms()
"""
if not self.contains_data():
return numpy.array([])
return numpy.asarray([numpy.sqrt(numpy.mean(self.get_ydata(i) ** 2))
for i in range(self.get_number_of_channels())])
def get_ystd(self) -> numpy.ndarray:
"""Return the population standard deviation of each channel.
Example:
deviation = accu.get_ystd()
"""
if not self.contains_data():
return numpy.array([])
return numpy.asarray([numpy.std(self.get_ydata(i))
for i in range(self.get_number_of_channels())])
+15 -8
View File
@@ -4,7 +4,7 @@ import sys
import tables
import numpy
import collections
from typing import IO
from typing import IO, Any, Literal, cast
from . import Drawable
## provide gaussian statistics for a series of measured data points
@@ -31,7 +31,10 @@ class AccumulatedValue:
self.y2=self.y**2
self.n=1
elif mean_err is None:
self.n=max(1, int(n))
if n is None:
self.n=1
else:
self.n=max(1, n)
self.y=float(mean)*self.n
self.y2=(float(mean)**2)*self.n
elif n is None:
@@ -240,7 +243,9 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
if complib is not None:
if complevel is None:
complevel=9
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
filter=tables.Filters(complevel=complevel,
complib=cast(Literal["blosc", "blosc2", "bzip2", "lzo", "zlib"], complib),
shuffle=True)
mr_table=hdffile.create_table(where=where,name=name,
description=h5_table_format,
@@ -248,11 +253,12 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
filters=filter,
expectedrows=len(self))
mr_table.flavor="numpy"
mr_table.attrs.damaris_type="MeasurementResult"
attrs = cast(Any, mr_table.attrs)
attrs.damaris_type="MeasurementResult"
self.lock.acquire()
try:
mr_table.attrs.quantity_name=self.quantity_name
row=mr_table.row
attrs.quantity_name=self.quantity_name
row = cast(Any, mr_table.row)
xdata=self.get_xdata()
if xdata.shape[0]!=0:
for x in xdata:
@@ -276,10 +282,11 @@ def read_from_hdf(hdf_node: object) -> "MeasurementResult | None":
if not isinstance(hdf_node, tables.Table):
return None
if hdf_node._v_attrs.damaris_type!="MeasurementResult":
attrs = cast(Any, hdf_node._v_attrs)
if attrs.damaris_type!="MeasurementResult":
return None
mr=MeasurementResult(hdf_node._v_attrs.quantity_name)
mr=MeasurementResult(attrs.quantity_name)
for r in hdf_node.iterrows():
mr[r["x"]]=AccumulatedValue(r["y"],r["y_err"],r["n"])
+5 -5
View File
@@ -22,10 +22,10 @@ class TemperatureResult(Resultable, Drawable):
Attributes:
x: List of timestamps in seconds.
y: List of temperature readings in Celsius.
setpoint: Optional list of setpoint temperatures in Celsius.
y: List of temperature readings in the source units.
setpoint: Optional list of setpoint temperatures in the source units.
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):
Resultable.__init__(self)
@@ -33,7 +33,7 @@ class TemperatureResult(Resultable, Drawable):
# Set default labels
self.xlabel = "Time (s)"
self.ylabel = "Temperature (C)"
self.ylabel = "Temperature"
# Initialize data lists
self.x = [] if x is None else x
@@ -229,7 +229,7 @@ def read_from_hdf(hdf_node):
tr = TemperatureResult()
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():
tr.x.append(r["x"])
+160 -364
View File
@@ -96,7 +96,7 @@ 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, TemperatureResult, FitResult
# TemperatureMonitor imported lazily to avoid circular import issues
from .TemperatureProxyClient import TemperatureProxyClient
# default, can be set to true from start script
debug = False
@@ -196,9 +196,7 @@ class DamarisGUI:
#to stop queued experiments
self.stop_experiment_flag = threading.Event()
# Temperature monitoring
self.temp_monitor = None
self.temp_controller = None
self.temperature_history_start = None
exp_script = ""
if exp_script_filename is not None and exp_script_filename != "":
@@ -253,6 +251,8 @@ 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_display_copy_png_button_clicked": self.monitor.copy_canvas_as_png,
"on_display_copy_svg_button_clicked": self.monitor.copy_canvas_as_svg,
"on_experiment_script_textview_event": self.sw.textviews_modified,
"on_main_notebook_switch_page": self.nopevent,
"main_window_close": self.nopevent
@@ -333,6 +333,9 @@ class DamarisGUI:
"""
expecting quit event for main application
"""
if self.state == DamarisGUI.Quit_State:
# quit already handled, e.g. window closed twice
return True
if self.state in [ DamarisGUI.Edit_State, DamarisGUI.Quit_State ]:
self.state = DamarisGUI.Quit_State
# do a cleanup...
@@ -681,12 +684,9 @@ 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)
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()
self.temperature_history_start = time.time()
self.si.runScripts( )
logger.debug("Script execution startup took %.3fs", time.time() - scripts_start)
except Exception as e:
@@ -864,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]
if len( still_running ) == 0:
# Stop temperature monitoring when experiment finishes
self.stop_temperature_monitoring()
if self.temperature_history_start is not None:
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 != "":
logger.info("All subprocesses ended, saving data pool")
# Disable run button during data saving to prevent UI confusion
@@ -1188,97 +1194,8 @@ class DamarisGUI:
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:
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 ):
doc_index_candidates = (
@@ -2459,75 +2376,6 @@ 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.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
components_text = """
operating system %(os)s
@@ -2634,29 +2482,11 @@ 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(),
# 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_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int()
}
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:
self.config_start_backend_checkbutton.set_active( config[ "start_backend" ] )
@@ -2688,40 +2518,6 @@ pygobject version %(pygobject)s
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( )
@@ -2738,82 +2534,6 @@ 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,
)
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 ):
if self.system_default_filename:
self.load_config( self.system_default_filename )
@@ -3024,6 +2744,18 @@ class BlitSafeCanvas( FigureCanvas ):
screen. Painting the region right away keeps every regular paint a
complete one and stays a partial update.
"""
def _update_device_pixel_ratio( self, *args, **kwargs ):
"""Render at logical resolution, ignoring the HiDPI scale factor.
The stock canvas renders one buffer pixel per physical pixel,
four times the pixel count on a scaled screen, which makes the
interactive plot sluggish. Capping the ratio at 1 renders a
quarter of the pixels; the window system scales the output up.
"""
if self._set_device_pixel_ratio( 1 ):
self.queue_resize( )
self.queue_draw( )
def blit( self, bbox=None ):
if bbox is None:
bbox = self.figure.bbox
@@ -3255,9 +2987,6 @@ class MonitorWidgets:
# add figure to canvas
self.matplot_canvas = BlitSafeCanvas( self.matplot_figure )
# add ctrl + right-click context menu to canvas
self.matplot_canvas.connect("button-press-event", self.on_canvas_button_press)
self.display_table = self.xml_gui.get_object( "display_table" )
self.display_table.attach( self.matplot_canvas, 0, 6, 0, 1, gtk.AttachOptions.EXPAND | gtk.AttachOptions.FILL, gtk.AttachOptions.EXPAND | gtk.AttachOptions.FILL, 0, 0 )
self.matplot_canvas.show( )
@@ -3292,6 +3021,16 @@ class MonitorWidgets:
self.display_table.attach( self.matplot_toolbar, 0, 1, 1, 2, gtk.AttachOptions.FILL | gtk.AttachOptions.EXPAND, 0, 0, 0 )
self.matplot_toolbar.show( )
# mouse pan (left button drag), single-axis zoom (middle button
# drag) and wheel zoom, active whenever no toolbar pan/zoom
# mode is on
self._pan_start = None
self._zoom_drag = None
self.matplot_canvas.mpl_connect( "scroll_event", self.plot_wheel_zoom )
self.matplot_canvas.mpl_connect( "button_press_event", self.plot_button_press )
self.matplot_canvas.mpl_connect( "motion_notify_event", self.plot_button_motion )
self.matplot_canvas.mpl_connect( "button_release_event", self.plot_button_release )
# select display source
self.display_source_combobox = self.xml_gui.get_object( "display_source_combobox" )
self.display_source_treestore = gtk.TreeStore( gobject.TYPE_STRING )
@@ -3728,7 +3467,7 @@ class MonitorWidgets:
# nothing to save
return
if not hasattr( data_to_save[ 1 ], "write_to_csv" ):
log( "do not know how to save %s of class/type %s" % (data_to_save[ 0 ], type( data_to_save[ 1 ] )) )
logger.warning( "do not know how to save %s of class/type %s" % (data_to_save[ 0 ], type( data_to_save[ 1 ] )) )
return
# save them to a temporary file (in memory)
@@ -3788,6 +3527,98 @@ class MonitorWidgets:
return True
@staticmethod
def _zoomed_limits( limits, center, factor, logarithmic ):
"""Return the limits zoomed by factor around center.
On logarithmic axes the scaling happens in log space.
"""
if logarithmic:
limits = numpy.log10( limits )
center = numpy.log10( center )
new_limits = [ center + ( limit - center ) * factor for limit in limits ]
if logarithmic:
new_limits = [ 10 ** limit for limit in new_limits ]
return tuple( new_limits )
@staticmethod
def _panned_limits( limits, grabbed, current, logarithmic ):
"""Return the limits shifted so that current moves to grabbed.
On logarithmic axes the shift happens in log space.
"""
if logarithmic:
shift = 10 ** ( numpy.log10( grabbed ) - numpy.log10( current ) )
return tuple( limit * shift for limit in limits )
shift = grabbed - current
return tuple( limit + shift for limit in limits )
def plot_wheel_zoom( self, event ):
"""Zoom the plot around the pointer position with the mouse wheel."""
if event.inaxes is not self.matplot_axes or event.xdata is None or event.ydata is None:
return
factor = 1.0 / 1.5 if event.step > 0 else 1.5
axes = self.matplot_axes
axes.set_xlim( self._zoomed_limits( axes.get_xlim( ), event.xdata, factor,
axes.get_xscale( ) == "log" ) )
axes.set_ylim( self._zoomed_limits( axes.get_ylim( ), event.ydata, factor,
axes.get_yscale( ) == "log" ) )
self.matplot_canvas.draw_idle( )
def plot_button_press( self, event ):
"""Start panning the plot with a left button drag.
A right button drag starts zooming single axes, like the right
button in the toolbar pan mode: horizontal motion zooms the x
axis, vertical motion the y axis. Both are inactive while a
toolbar pan/zoom mode is on, so the toolbar behavior stays
unchanged.
"""
if event.inaxes is not self.matplot_axes or event.xdata is None or event.ydata is None:
return
if event.button == 3 and not self.matplot_toolbar.mode:
self._zoom_drag = ( event.x, event.y, event.xdata, event.ydata )
elif event.button == 1 and not self.matplot_toolbar.mode:
self._pan_start = ( event.xdata, event.ydata )
def plot_button_motion( self, event ):
"""Pan the plot so the grabbed data point follows the pointer.
With the right button held, zoom single axes around the
pressed position instead: horizontal motion zooms the x axis,
vertical motion the y axis, dragging right or up zooms in.
"""
if self._zoom_drag is not None and event.x is not None and event.y is not None:
x0, y0, xdata0, ydata0 = self._zoom_drag
axes = self.matplot_axes
dx = event.x - x0
dy = event.y - y0
if dx:
factor = 10 ** ( -dx / axes.bbox.width )
axes.set_xlim( self._zoomed_limits( axes.get_xlim( ), xdata0, factor,
axes.get_xscale( ) == "log" ) )
if dy:
factor = 10 ** ( -dy / axes.bbox.height )
axes.set_ylim( self._zoomed_limits( axes.get_ylim( ), ydata0, factor,
axes.get_yscale( ) == "log" ) )
self.matplot_canvas.draw_idle( )
return
if self._pan_start is None or event.xdata is None or event.ydata is None:
return
x0, y0 = self._pan_start
axes = self.matplot_axes
axes.set_xlim( self._panned_limits( axes.get_xlim( ), x0, event.xdata,
axes.get_xscale( ) == "log" ) )
axes.set_ylim( self._panned_limits( axes.get_ylim( ), y0, event.ydata,
axes.get_yscale( ) == "log" ) )
self._pan_start = ( event.xdata, event.ydata )
self.matplot_canvas.draw_idle( )
def plot_button_release( self, event ):
"""Stop panning or single-axis zooming the plot."""
self._pan_start = None
self._zoom_drag = None
def toggle_cursor(self, widget):
"""Toggle matplotlib crosshair cursor"""
if widget.get_active():
@@ -3810,40 +3641,6 @@ class MonitorWidgets:
self.matplot_cursor = None
self.matplot_canvas.draw()
def on_canvas_button_press(self, widget, event):
"""Handle button press events on matplotlib canvas"""
if event.button == 2: # middle click
self.show_canvas_context_menu(event)
return True
return False
def show_canvas_context_menu(self, event):
"""Show context menu for matplotlib canvas"""
menu = gtk.Menu()
# toggle cursor
toggle_cursor = gtk.CheckMenuItem(label="Crosshair")
# Set the current state based on whether cursor exists
if hasattr(self, 'matplot_cursor') and self.matplot_cursor is not None:
toggle_cursor.set_active(True)
else:
toggle_cursor.set_active(False)
toggle_cursor.connect("toggled", lambda w: self.matplot_cursor_button.set_active(w.get_active()))
menu.append(toggle_cursor)
# Copy as PNG
item_copy_png = gtk.MenuItem(label="Copy as PNG")
item_copy_png.connect("activate", self.copy_canvas_as_png)
menu.append(item_copy_png)
# Copy as SVG
item_copy_svg = gtk.MenuItem(label="Copy as SVG")
item_copy_svg.connect("activate", self.copy_canvas_as_svg)
menu.append(item_copy_svg)
menu.show_all()
menu.popup_at_pointer(event)
def copy_canvas_as_png(self, widget):
"""Copy canvas to clipboard as PNG"""
self.copy_display_data_to_clipboard(widget)
@@ -3854,25 +3651,20 @@ class MonitorWidgets:
return
try:
# Save the original figure size
original_size = self.matplot_figure.get_size_inches()
# Save figure to a buffer as SVG
import io as iomodule
buf = iomodule.StringIO()
self.matplot_figure.set_size_inches(6, 4)
self.matplot_figure.savefig(buf, format='svg', bbox_inches='tight')
buf.seek(0)
# Copy SVG text to clipboard
clipboard = gtk.Clipboard.get(gdk.SELECTION_CLIPBOARD)
clipboard.set_text(buf.read(), -1)
clipboard.store()
self.matplot_figure.set_size_inches(original_size)
log("Figure copied to clipboard as SVG")
logger.info("Figure copied to clipboard as SVG")
except Exception as e:
log(f"Error copying figure as SVG: {e}")
logger.error("Error copying figure as SVG: %s", e)
def copy_display_data_to_clipboard( self, widget, data=None ):
"""
@@ -3880,38 +3672,42 @@ class MonitorWidgets:
Also keep text data copy functionality as fallback
"""
# First, try to copy the matplotlib figure as image
# Save the original figure size
original_size = self.matplot_figure.get_size_inches()
if self.matplot_figure is not None and self.matplot_canvas is not None:
try:
# Save figure to a temporary buffer as PNG
import io as iomodule
buf = iomodule.BytesIO()
self.matplot_figure.set_size_inches(6, 4)
self.matplot_figure.savefig(buf, format='png', dpi=300, bbox_inches='tight')
buf.seek(0)
# Load the PNG data into a GdkPixbuf
from gi.repository import GdkPixbuf
loader = GdkPixbuf.PixbufLoader.new_with_type('png')
loader.write(buf.read())
loader.close()
pixbuf = loader.get_pixbuf()
# Render off-screen at a fixed size (12:8 inches, 800 px
# wide) on a temporary Agg canvas. Snapshotting the GTK
# canvas buffer instead would copy the window size, and
# re-rendering through the GTK canvas would replace its
# renderer and leave the on-screen plot blank.
from matplotlib.backends.backend_agg import FigureCanvasAgg
figure = self.matplot_figure
orig_canvas = figure.canvas
orig_size = figure.get_size_inches( )
orig_dpi = figure.dpi
try:
agg_canvas = FigureCanvasAgg( figure )
figure.set_size_inches( 12, 8 )
figure.dpi = 800 / 12
agg_canvas.draw( )
rgba = numpy.asarray( agg_canvas.buffer_rgba( ) )
finally:
figure.set_canvas( orig_canvas )
figure.set_size_inches( *orig_size )
figure.dpi = orig_dpi
height, width = rgba.shape[ :2 ]
pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
glib.Bytes.new( rgba.tobytes( ) ), GdkPixbuf.Colorspace.RGB, True, 8,
width, height, width * 4 )
# Copy to clipboard
clipboard = gtk.Clipboard.get( gdk.SELECTION_CLIPBOARD )
clipboard.set_image( pixbuf )
clipboard.store()
# Restore original figure size
self.matplot_figure.set_size_inches(original_size)
# Redraw the canvas to ensure it stays visible
self.matplot_canvas.draw()
log("Figure copied to clipboard as PNG image")
logger.info("Figure copied to clipboard as PNG image")
return
except Exception as e:
log("Error copying figure to clipboard: %s" % str(e))
logger.error("Error copying figure to clipboard: %s", e)
##################### functions to feed display
def clear_display( self ):
-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
+48 -2
View File
@@ -1414,10 +1414,56 @@ Public License instead of this License.
</packing>
</child>
<child>
<placeholder/>
<object class="GtkButton" id="display_copy_png_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="tooltip-text" translatable="yes">Copy plot as PNG image to clipboard</property>
<signal name="clicked" handler="on_display_copy_png_button_clicked" swapped="no"/>
<child>
<object class="GtkLabel" id="label_copy_png">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="xpad">10</property>
<property name="label" translatable="yes">&lt;span foreground="#0000ff"&gt;PNG&lt;/span&gt;</property>
<property name="use-markup">True</property>
</object>
</child>
</object>
<packing>
<property name="left-attach">3</property>
<property name="right-attach">4</property>
<property name="top-attach">1</property>
<property name="bottom-attach">2</property>
<property name="x-options"/>
<property name="y-options">GTK_FILL</property>
</packing>
</child>
<child>
<placeholder/>
<object class="GtkButton" id="display_copy_svg_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="tooltip-text" translatable="yes">Copy plot as SVG to clipboard</property>
<signal name="clicked" handler="on_display_copy_svg_button_clicked" swapped="no"/>
<child>
<object class="GtkLabel" id="label_copy_svg">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="xpad">10</property>
<property name="label" translatable="yes">&lt;span foreground="#0000ff"&gt;SVG&lt;/span&gt;</property>
<property name="use-markup">True</property>
</object>
</child>
</object>
<packing>
<property name="left-attach">2</property>
<property name="right-attach">3</property>
<property name="top-attach">1</property>
<property name="bottom-attach">2</property>
<property name="x-options"/>
<property name="y-options">GTK_FILL</property>
</packing>
</child>
<child>
<placeholder/>
+2 -1
View File
@@ -2,7 +2,8 @@
"""
Unified logging for DAMARIS.
Provides:
Provides these components:
- FileTailerHandler: reads new lines from the backend log file and emits them
as logging records into the damaris.backend logger.
- LogDispatcher: a logging.Handler that dispatches records to both a GTK
-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)
+2
View File
@@ -15,6 +15,7 @@ def result():
data["data/ts %i"%num] = ts+0
ts.write_to_hdf(h5, "/", f"adc_{num}", f"adc_{num}")
data["fft"] = (ts+0).fft()
data["fft_autophase"] = (ts+0).fft().autophase()
r[num] = ts.y[0][0]
if num > 4:
x = r.get_xdata()
@@ -32,6 +33,7 @@ def result():
data["test"] = r
accu+=ts
print(accu)
data["accu_fft"] = accu.fft().autophase()
accu.write_to_hdf(h5, "/", "name", "title")
try:
h5.close()
+15
View File
@@ -30,6 +30,21 @@ class TestADCResult(unittest.TestCase):
self.assertEqual(len(adc.x), 0)
self.assertEqual(len(adc.y), 0)
def test_shared_measurement_statistics(self):
adc = self.create_adc_result()
np.testing.assert_allclose(adc.get_ymean(), [20, 25])
np.testing.assert_allclose(adc.get_ymin_channels(), [10, 15])
np.testing.assert_allclose(adc.get_ymax_channels(), [30, 35])
np.testing.assert_allclose(adc.get_yrms(),
[np.sqrt(np.mean(np.array([10, 20, 30]) ** 2)),
np.sqrt(np.mean(np.array([15, 25, 35]) ** 2))])
np.testing.assert_allclose(adc.get_ystd(), [np.std([10, 20, 30]),
np.std([15, 25, 35])])
def test_shared_phase_uses_public_channel_values(self):
adc = self.create_adc_result()
self.assertAlmostEqual(adc.get_phase(), np.degrees(np.arctan2(15, 10)))
def test_constructor_with_arguments(self):
"""Test initializer with all arguments."""
x = np.array([0.0, 1.0, 2.0])
+22
View File
@@ -33,6 +33,14 @@ class TestAccumulation(unittest.TestCase):
self.assertEqual(len(accu.x), 0)
self.assertEqual(len(accu.y), 0)
def test_shared_measurement_statistics_use_accumulation_means(self):
accu = Accumulation()
accu += self.create_adc_result()
np.testing.assert_allclose(accu.get_ymean(), [20, 25])
np.testing.assert_allclose(accu.get_ymin_channels(), [10, 15])
np.testing.assert_allclose(accu.get_ymax_channels(), [30, 35])
self.assertAlmostEqual(accu.get_phase(), np.degrees(np.arctan2(15, 10)), places=6)
def test_constructor_with_arguments(self):
"""Test initializer with all arguments."""
x = np.array([0.0, 1.0, 2.0])
@@ -352,6 +360,20 @@ class TestAccumulation(unittest.TestCase):
r *= 2
np.testing.assert_array_equal(accu.y[0], np.array([10, 20, 30], dtype="float32"))
def test_empty_iadd_accumulation_without_common_descriptions(self):
"""An accumulation without common descriptions can be copied in-place."""
other = Accumulation(x=np.array([0.0, 1.0]),
y=[np.array([10.0, 20.0])],
n=1,
index=[(0, 1)],
sampl_freq=1000.0)
accu = Accumulation()
accu += other
self.assertIsNone(accu.common_descriptions)
np.testing.assert_array_equal(accu.y[0], other.y[0])
def test_accumulation_plus_accumulation_keeps_both_job_ids(self):
"""accu1 + accu2 must keep the provenance of both operands."""
adc1 = self.create_adc_result()
+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()