Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e42205a4d | ||
|
|
1f6763f9de | ||
|
|
6668ac5b2b | ||
|
|
ee453c03a7 | ||
|
|
1fb56e20f9 | ||
|
|
ef11824f8b |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"linters": {
|
||||
"sample": {
|
||||
"type": "pep8",
|
||||
"include": "(\\.py$)",
|
||||
"exclude": "(^third-party/)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,25 +22,13 @@ jobs:
|
||||
- name: Install Node.js and git
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get -y install nodejs git lsb-release jq devscripts
|
||||
apt-get -y install nodejs git lsb-release jq
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ debian/files
|
||||
.junie
|
||||
debian/debhelper-build-stamp
|
||||
debian/.debhelper
|
||||
podman
|
||||
.kilo
|
||||
|
||||
# Sphinx documentation
|
||||
doc/_build/
|
||||
|
||||
@@ -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 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!
|
||||
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.
|
||||
Vendored
-23
@@ -1,26 +1,3 @@
|
||||
python3-damaris (0.31) stable; urgency=medium
|
||||
|
||||
* added phasing slider
|
||||
* added persistent ADC overflow indicator
|
||||
|
||||
-- Markus Rosenstihl <markus.rosenstihl@pkm.tu-darmstadt.de> Thu, 22 Oct 2026 01:00:32 +0200
|
||||
|
||||
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
|
||||
|
||||
+15
-1
@@ -5,6 +5,16 @@ 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
|
||||
===========
|
||||
|
||||
@@ -60,7 +70,7 @@ The GUI module contains the graphical interface, backend driver, and experiment/
|
||||
damaris.gui.ExperimentWriter
|
||||
damaris.gui.ResultHandling
|
||||
damaris.gui.ResultReader
|
||||
damaris.gui.TemperatureProxyClient
|
||||
damaris.gui.TemperatureMonitor
|
||||
damaris.gui.logging_utils
|
||||
|
||||
A short usage example for the font helper of the GUI module (the non-deprecated
|
||||
@@ -87,7 +97,11 @@ 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
@@ -33,7 +33,7 @@ bibtex_bibstyles = ['numeric']
|
||||
bibtex_citestyle = 'numeric'
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'damaris/**']
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
language = 'en'
|
||||
|
||||
@@ -42,4 +42,4 @@ language = 'en'
|
||||
|
||||
#html_theme = 'alabaster'
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_static_path = []
|
||||
html_static_path = ['_static']
|
||||
+5
-55
@@ -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. 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.
|
||||
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.
|
||||
|
||||
|
||||
|
||||
@@ -53,11 +53,6 @@ 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
|
||||
@@ -105,20 +100,6 @@ 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. The crosshair button and the Pan button in the toolbar below the plot are mutually exclusive; activating one automatically deactivates the other. Note that with the autoscaling option enabled, the view is reset to the full data range whenever a new result arrives.
|
||||
|
||||
A horizontal slider (0-360°) with a "Phase:" label and a value read-out is available in the "Display" tab, located to the right of the "Source:" drop-down selector. This slider allows for a display-only interactive rotation of the displayed ADC_Result or Accumulation two-channel signal for visual phase correction. The rotation applies to all displayed scans and updates for the selected source.
|
||||
|
||||
This operation is display-only and is not written to HDF; it does not modify the result script data or affect CSV exports. The phase is reset to 0 when a new experiment or data pool is loaded. It is inert for data with a single channel or non-signal sources (e.g., TemperatureResult). Note that error bars and statistics are deliberately not rotated, so with the statistics option enabled and a non-zero phase, the error envelope will no longer bracket the rotated curve. For 90°, 180°, and 270° steps, the exact fast path in the phase calculation is used to avoid rounding noise.
|
||||
|
||||
The same rotation is available programmatically via the ``phase()`` method of the ``accu`` (Accumulation) and ``timesignal`` (ADC_Result) objects, which returns a rotated copy of the data without modifying the original object.
|
||||
|
||||
.. code-block:: python
|
||||
:caption: Example of programmatic phase rotation
|
||||
:linenos:
|
||||
|
||||
rotated = accu.phase(90) # returns a rotated copy, accu is unchanged
|
||||
data["Rotated"] = rotated
|
||||
|
||||
Help Menu
|
||||
=========
|
||||
@@ -407,7 +388,7 @@ All four are commonly used in NMR spectroscopy. The line broadening factor LB is
|
||||
.. math::
|
||||
f(x)=exp(-tp \cdot LB - GM \cdot tp^2)
|
||||
|
||||
- :meth:`damaris.data.DamarisFFT.DamarisFFT.traf_window` (line_broadening=10)
|
||||
- :meth:`damaris.data.DamarisFFT.DamarisFFT.traf_window`(line_broadening=10 )
|
||||
This sets a TRAF window :cite:p:`Traficante:1987fk` over the data:
|
||||
|
||||
.. math::
|
||||
@@ -475,9 +456,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.
|
||||
|
||||
@@ -680,9 +661,8 @@ 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.
|
||||
|
||||
@@ -776,36 +756,6 @@ 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::
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/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"
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## module contents
|
||||
#
|
||||
__version__ = "0.31"
|
||||
__version__ = "0.25"
|
||||
__all__=["experiments", "data", "gui"]
|
||||
|
||||
import sys
|
||||
|
||||
@@ -6,20 +6,18 @@ 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, Any, Literal, cast
|
||||
from typing import IO
|
||||
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 #
|
||||
@@ -29,7 +27,7 @@ _CompressionLibrary = Literal["blosc", "blosc2", "bzip2", "lzo", "zlib"]
|
||||
# #
|
||||
#############################################################################
|
||||
|
||||
class ADC_Result(MeasurementData, Resultable, Drawable, DamarisFFT, Signalpath):
|
||||
class ADC_Result(Resultable, Drawable, DamarisFFT, Signalpath):
|
||||
default_bit_depth = 14
|
||||
"""
|
||||
Represents the result of an ADC, encapsulating data and metadata
|
||||
@@ -378,14 +376,13 @@ class ADC_Result(MeasurementData, 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)
|
||||
attrs = cast(Any, accu_group._v_attrs)
|
||||
attrs.damaris_type="ADC_Result"
|
||||
accu_group._v_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:
|
||||
attrs.time="%04d%02d%02d %02d:%02d:%02d.%03d"%(self.job_date.year,
|
||||
accu_group._v_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,
|
||||
@@ -395,17 +392,15 @@ class ADC_Result(MeasurementData, Resultable, Drawable, DamarisFFT, Signalpath):
|
||||
|
||||
if self.description is not None:
|
||||
for (key,value) in self.description.items():
|
||||
attrs.__setattr__("description_"+key,str(value))
|
||||
attrs.__setattr__("sampling_rate",self.sampling_rate)
|
||||
accu_group._v_attrs.__setattr__("description_"+key,str(value))
|
||||
accu_group._v_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=cast(_CompressionLibrary, complib),
|
||||
shuffle=True)
|
||||
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
|
||||
|
||||
index_table=hdffile.create_table(where=accu_group,
|
||||
name="indices",
|
||||
@@ -418,7 +413,7 @@ class ADC_Result(MeasurementData, Resultable, Drawable, DamarisFFT, Signalpath):
|
||||
expectedrows=len(self.index))
|
||||
index_table.flavor="numpy"
|
||||
# save channel data
|
||||
new_row = cast(Any, index_table.row)
|
||||
new_row=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
|
||||
@@ -440,14 +435,27 @@ class ADC_Result(MeasurementData, Resultable, Drawable, DamarisFFT, Signalpath):
|
||||
# save data
|
||||
time_slice_data=None
|
||||
if filter is not None:
|
||||
chunkshape = (min(timedata.shape[0], 1024 * 8), timedata.shape[1])
|
||||
time_slice_data=hdffile.create_carray(accu_group,
|
||||
name="adc_data",
|
||||
shape=timedata.shape,
|
||||
chunkshape=chunkshape,
|
||||
atom=tables.Int16Atom(),
|
||||
filters=filter,
|
||||
title="adc data")
|
||||
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:
|
||||
time_slice_data=hdffile.create_carray(accu_group,
|
||||
name="adc_data",
|
||||
shape=timedata.shape,
|
||||
chunkshape=chunkshape,
|
||||
atom=tables.Int16Atom(),
|
||||
filters=filter,
|
||||
title="adc data")
|
||||
time_slice_data[:]=timedata
|
||||
else:
|
||||
time_slice_data=hdffile.create_array(accu_group,
|
||||
@@ -675,8 +683,7 @@ def read_from_hdf(hdf_node: object) -> ADC_Result | None:
|
||||
if not isinstance(hdf_node, tables.Group):
|
||||
return None
|
||||
|
||||
attrs = cast(Any, hdf_node._v_attrs)
|
||||
if attrs.damaris_type!="ADC_Result":
|
||||
if hdf_node._v_attrs.damaris_type!="ADC_Result":
|
||||
return None
|
||||
|
||||
if not (hdf_node.__contains__("indices") and hdf_node.__contains__("adc_data")):
|
||||
@@ -688,12 +695,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 attrs._v_attrnamesuser:
|
||||
for attrname in hdf_node._v_attrs._v_attrnamesuser:
|
||||
if attrname.startswith("description_"):
|
||||
adc.description[attrname[12:]]=attrs.__getattr__(attrname)
|
||||
adc.description[attrname[12:]]=hdf_node._v_attrs.__getattr__(attrname)
|
||||
|
||||
if "time" in dir(attrs):
|
||||
timestring=attrs.__getattr__("time")
|
||||
if "time" in dir(hdf_node._v_attrs):
|
||||
timestring=hdf_node._v_attrs.__getattr__("time")
|
||||
adc.job_date=datetime.datetime(int(timestring[:4]), # year
|
||||
int(timestring[4:6]), # month
|
||||
int(timestring[6:8]), # day
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -26,16 +25,15 @@ 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, Any, Literal, cast
|
||||
from typing import IO
|
||||
|
||||
_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(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath):
|
||||
class Accumulation(Errorable, Drawable, DamarisFFT, Signalpath):
|
||||
"""
|
||||
Represents an accumulation of sampled data with various attributes and methods
|
||||
for data manipulation, analysis, and exporting.
|
||||
@@ -223,10 +221,10 @@ class Accumulation(MeasurementData, 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()):
|
||||
@@ -259,24 +257,12 @@ class Accumulation(MeasurementData, 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:
|
||||
"""
|
||||
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 " ".
|
||||
writes the data to a file.
|
||||
destination can be a filehandle or a filename, default sys.stdout
|
||||
"""
|
||||
if isinstance(destination, str):
|
||||
with open(destination, "w") as f:
|
||||
@@ -308,7 +294,7 @@ class Accumulation(MeasurementData, 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 and yerr is not None:
|
||||
if self.use_error:
|
||||
dest.write("%s%e%s%e"%(delimiter, ydata[j][i], delimiter, yerr[j][i]))
|
||||
else:
|
||||
dest.write("%s%e"%(delimiter,ydata[j][i]))
|
||||
@@ -319,18 +305,9 @@ class Accumulation(MeasurementData, 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:
|
||||
"""
|
||||
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.
|
||||
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
|
||||
"""
|
||||
if isinstance(destination, str):
|
||||
with open(destination, "w") as f:
|
||||
@@ -359,27 +336,15 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
self.lock.release()
|
||||
|
||||
# ------------- added by Oleg Petrov, 10 Sep 2013 -----------------------
|
||||
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:
|
||||
def write_to_tecmag(self, destination = sys.stdout, nrecords: int = 1,\
|
||||
frequency: float = 100.,\
|
||||
last_delay: float = 1.,\
|
||||
receiver_phase: float = 0.,\
|
||||
nucleus: str = '1H') -> None:
|
||||
"""
|
||||
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'.
|
||||
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;
|
||||
"""
|
||||
|
||||
#TODO: Function is most likely broken in Python 3 because Strings are now unicode and binary files cannot write unicode
|
||||
@@ -389,8 +354,8 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
else:
|
||||
record = (self.job_id/self.n)%nrecords + 1
|
||||
|
||||
the_destination: IO[bytes] | None = None
|
||||
if isinstance(destination, str):
|
||||
the_destination=destination
|
||||
if type(destination) in (str,):
|
||||
if record == 1 and os.path.exists(destination):
|
||||
os.rename(destination, os.path.dirname(destination)+'/~'+os.path.basename(destination))
|
||||
|
||||
@@ -421,10 +386,7 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
data = data.flatten()
|
||||
|
||||
if record == 1:
|
||||
if isinstance(destination, str):
|
||||
the_destination = open(destination, "wb")
|
||||
else:
|
||||
the_destination = destination
|
||||
the_destination=open(destination, "wb")
|
||||
|
||||
# allocate space for all records in advance:
|
||||
buff = ctypes.create_string_buffer(1056+data_offset+2068)
|
||||
@@ -485,69 +447,56 @@ class Accumulation(MeasurementData, 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.raw)
|
||||
the_destination.write(buff)
|
||||
|
||||
else:
|
||||
if isinstance(destination, str):
|
||||
the_destination = open(destination, "rb+")
|
||||
else:
|
||||
the_destination = destination
|
||||
the_destination=open(destination, "rb+")
|
||||
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.raw)
|
||||
the_destination.write(buff)
|
||||
|
||||
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 using PyTables.
|
||||
Write the accumulation data to an HDF5 file.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
accu_group=hdffile.create_group(where=where,name=name,title=title)
|
||||
attrs = cast(Any, accu_group._v_attrs)
|
||||
attrs.damaris_type="Accumulation"
|
||||
accu_group._v_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:
|
||||
attrs.earliest_time = self.time_period[0].isoformat(sep=' ', timespec='milliseconds')
|
||||
attrs.oldest_time = self.time_period[1].isoformat(sep=' ', timespec='milliseconds')
|
||||
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')
|
||||
if self.common_descriptions is not None:
|
||||
for (key,value) in self.common_descriptions.items():
|
||||
attrs.__setattr__("description_"+key,str(value))
|
||||
attrs.__setattr__("sampling_rate",self.sampling_rate)
|
||||
accu_group._v_attrs.__setattr__("description_"+key,str(value))
|
||||
accu_group._v_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=cast(_CompressionLibrary, complib),
|
||||
shuffle=True)
|
||||
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
|
||||
|
||||
# save interval information
|
||||
# start save index_table
|
||||
@@ -564,7 +513,7 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
expectedrows=len(self.index))
|
||||
index_table.flavor="numpy"
|
||||
# save interval data
|
||||
new_row = cast(Any, index_table.row)
|
||||
new_row=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
|
||||
@@ -585,7 +534,7 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
title="job_ids used for accumulation",
|
||||
filters=filter,
|
||||
expectedrows=len(self.job_ids))
|
||||
new_row = cast(Any, jobid_table.row)
|
||||
new_row=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
|
||||
@@ -606,14 +555,27 @@ class Accumulation(MeasurementData, Errorable, Drawable, DamarisFFT, Signalpath)
|
||||
# save data
|
||||
time_slice_data=None
|
||||
if filter is not None:
|
||||
chunkshape = (min(timedata.shape[0], 1024 * 8), timedata.shape[1])
|
||||
time_slice_data=hdffile.create_carray(accu_group,
|
||||
name="accu_data",
|
||||
shape=timedata.shape,
|
||||
chunkshape=chunkshape,
|
||||
atom=tables.Float32Atom(),
|
||||
filters=filter,
|
||||
title="accu data")
|
||||
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:
|
||||
time_slice_data=hdffile.create_carray(accu_group,
|
||||
name="accu_data",
|
||||
shape=timedata.shape,
|
||||
chunkshape=chunkshape,
|
||||
atom=tables.Float32Atom(),
|
||||
filters=filter,
|
||||
title="accu data")
|
||||
|
||||
time_slice_data[:]=timedata
|
||||
else:
|
||||
@@ -632,6 +594,13 @@ class Accumulation(MeasurementData, 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)"""
|
||||
@@ -1109,8 +1078,7 @@ class Accumulation(MeasurementData, 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()
|
||||
if other.common_descriptions is not None else None)
|
||||
self.common_descriptions=other.common_descriptions.copy()
|
||||
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)
|
||||
@@ -1212,8 +1180,7 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
|
||||
if not isinstance(hdf_node, tables.Group):
|
||||
return None
|
||||
|
||||
attrs = cast(Any, hdf_node._v_attrs)
|
||||
if attrs.damaris_type!="Accumulation":
|
||||
if hdf_node._v_attrs.damaris_type!="Accumulation":
|
||||
return None
|
||||
|
||||
if not (hdf_node.__contains__("indices") and hdf_node.__contains__("accu_data")):
|
||||
@@ -1224,13 +1191,13 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
|
||||
|
||||
# populate description dictionary
|
||||
accu.common_descriptions={}
|
||||
for attrname in attrs._v_attrnamesuser:
|
||||
for attrname in hdf_node._v_attrs._v_attrnamesuser:
|
||||
if attrname.startswith("description_"):
|
||||
accu.common_descriptions[attrname[12:]]=attrs.__getattr__(attrname)
|
||||
accu.common_descriptions[attrname[12:]]=hdf_node._v_attrs.__getattr__(attrname)
|
||||
|
||||
earliest_time=None
|
||||
if "earliest_time" in dir(attrs):
|
||||
timestring=attrs.__getattr__("earliest_time")
|
||||
if "earliest_time" in dir(hdf_node._v_attrs):
|
||||
timestring=hdf_node._v_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"
|
||||
@@ -1261,8 +1228,8 @@ def read_from_hdf(hdf_node: object) -> "Accumulation | None":
|
||||
)
|
||||
|
||||
oldest_time=None
|
||||
if "oldest_time" in dir(attrs):
|
||||
timestring=attrs.__getattr__("oldest_time")
|
||||
if "oldest_time" in dir(hdf_node._v_attrs):
|
||||
timestring=hdf_node._v_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"
|
||||
|
||||
@@ -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,28 +118,44 @@ 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.
|
||||
|
||||
: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.
|
||||
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.
|
||||
|
||||
Notes
|
||||
-----
|
||||
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:
|
||||
- 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.
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# -*- 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())])
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
import tables
|
||||
import numpy
|
||||
import collections
|
||||
from typing import IO, Any, Literal, cast
|
||||
from typing import IO
|
||||
from . import Drawable
|
||||
|
||||
## provide gaussian statistics for a series of measured data points
|
||||
@@ -31,10 +31,7 @@ class AccumulatedValue:
|
||||
self.y2=self.y**2
|
||||
self.n=1
|
||||
elif mean_err is None:
|
||||
if n is None:
|
||||
self.n=1
|
||||
else:
|
||||
self.n=max(1, n)
|
||||
self.n=max(1, int(n))
|
||||
self.y=float(mean)*self.n
|
||||
self.y2=(float(mean)**2)*self.n
|
||||
elif n is None:
|
||||
@@ -243,9 +240,7 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
|
||||
if complib is not None:
|
||||
if complevel is None:
|
||||
complevel=9
|
||||
filter=tables.Filters(complevel=complevel,
|
||||
complib=cast(Literal["blosc", "blosc2", "bzip2", "lzo", "zlib"], complib),
|
||||
shuffle=True)
|
||||
filter=tables.Filters(complevel=complevel,complib=complib,shuffle=1)
|
||||
|
||||
mr_table=hdffile.create_table(where=where,name=name,
|
||||
description=h5_table_format,
|
||||
@@ -253,12 +248,11 @@ class MeasurementResult(Drawable.Drawable, collections.UserDict):
|
||||
filters=filter,
|
||||
expectedrows=len(self))
|
||||
mr_table.flavor="numpy"
|
||||
attrs = cast(Any, mr_table.attrs)
|
||||
attrs.damaris_type="MeasurementResult"
|
||||
mr_table.attrs.damaris_type="MeasurementResult"
|
||||
self.lock.acquire()
|
||||
try:
|
||||
attrs.quantity_name=self.quantity_name
|
||||
row = cast(Any, mr_table.row)
|
||||
mr_table.attrs.quantity_name=self.quantity_name
|
||||
row=mr_table.row
|
||||
xdata=self.get_xdata()
|
||||
if xdata.shape[0]!=0:
|
||||
for x in xdata:
|
||||
@@ -282,11 +276,10 @@ def read_from_hdf(hdf_node: object) -> "MeasurementResult | None":
|
||||
if not isinstance(hdf_node, tables.Table):
|
||||
return None
|
||||
|
||||
attrs = cast(Any, hdf_node._v_attrs)
|
||||
if attrs.damaris_type!="MeasurementResult":
|
||||
if hdf_node._v_attrs.damaris_type!="MeasurementResult":
|
||||
return None
|
||||
|
||||
mr=MeasurementResult(attrs.quantity_name)
|
||||
mr=MeasurementResult(hdf_node._v_attrs.quantity_name)
|
||||
for r in hdf_node.iterrows():
|
||||
mr[r["x"]]=AccumulatedValue(r["y"],r["y_err"],r["n"])
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ class TemperatureResult(Resultable, Drawable):
|
||||
|
||||
Attributes:
|
||||
x: List of timestamps in seconds.
|
||||
y: List of temperature readings in the source units.
|
||||
setpoint: Optional list of setpoint temperatures in the source units.
|
||||
y: List of temperature readings in Celsius.
|
||||
setpoint: Optional list of setpoint temperatures in Celsius.
|
||||
xlabel: Label for x-axis (default: "Time (s)").
|
||||
ylabel: Label for y-axis (default: "Temperature").
|
||||
ylabel: Label for y-axis (default: "Temperature (C)").
|
||||
"""
|
||||
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"
|
||||
self.ylabel = "Temperature (C)"
|
||||
|
||||
# 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")
|
||||
tr.ylabel = getattr(hdf_node._v_attrs, "ylabel", "Temperature (C)")
|
||||
|
||||
for r in hdf_node.iterrows():
|
||||
tr.x.append(r["x"])
|
||||
|
||||
@@ -13,7 +13,7 @@ class StateSimple(StateBase):
|
||||
super(StateSimple, self).__init__()
|
||||
if time < 0:
|
||||
raise AssertionError("time for state is negative!")
|
||||
self.time = time
|
||||
self.time = float(time)
|
||||
self.content = content
|
||||
|
||||
def to_xml(self, indent: str = "") -> str:
|
||||
@@ -191,7 +191,7 @@ class Experiment:
|
||||
|
||||
Typically only used in very special cases.
|
||||
"""
|
||||
self.state_list.append('<state time="%s">\n' % repr(time))
|
||||
self.state_list.append('<state time="%s">\n' % repr(float(time)))
|
||||
self.total_time[-1] += time
|
||||
|
||||
## End of *state_start*
|
||||
@@ -404,10 +404,10 @@ class Experiment:
|
||||
The state length is 2 µs. Stabilisation time depends on RF source.
|
||||
|
||||
:param float frequency: frequency to set in Hz
|
||||
:param float phase: phase to set
|
||||
:param float phase: phase to set, normalized to the range [0, 360)
|
||||
:param int ttls: default=0, additional ttl lines to set (integer)
|
||||
"""
|
||||
s_content = '<analogout id="0" f="%f" phase="%f"/>' % (frequency, phase)
|
||||
s_content = '<analogout id="0" f="%f" phase="%f"/>' % (frequency, phase % 360)
|
||||
if ttls != 0:
|
||||
s_content += '<ttlout value="0x%06x"/>' % ttls
|
||||
self.state_list.append(StateSimple(2e-6, s_content))
|
||||
@@ -582,8 +582,9 @@ class Experiment:
|
||||
- The state length for this command is 0.5 µs.
|
||||
- The stabilization time for the phase change is RF source dependent. A typical value for PTS310 is 2 µs.
|
||||
- The phase is relative to the initial phase set at start of the experiment.
|
||||
- Negative phases are normalized to the range [0, 360) before writing the job file.
|
||||
"""
|
||||
s_content = '<analogout phase="%f" />' % (phase)
|
||||
s_content = '<analogout phase="%f" />' % (phase % 360)
|
||||
if ttls!=0:
|
||||
s_content += '<ttlout value="%d"/>' % ttls
|
||||
self.state_list.append(StateSimple(0.5e-6, s_content))
|
||||
@@ -680,19 +681,19 @@ class Experiment:
|
||||
value=""
|
||||
elif type(value) is float or isinstance(value, numpy.floating):
|
||||
type_string="Float"
|
||||
value=repr(value)
|
||||
value=repr(float(value))
|
||||
elif type(value) is int or isinstance(value, numpy.integer):
|
||||
type_string="Int"
|
||||
value=repr(value)
|
||||
value=repr(int(value))
|
||||
elif type(value) is int:
|
||||
type_string="Long"
|
||||
value=repr(value)
|
||||
elif type(value) is complex or isinstance(value, numpy.complexfloating):
|
||||
type_string="Complex"
|
||||
value=repr(value)
|
||||
value=repr(complex(value))
|
||||
elif type(value) is bool or isinstance(value, numpy.bool_):
|
||||
type_string="Boolean"
|
||||
value=repr(value)
|
||||
value=repr(bool(value))
|
||||
elif type(value) in (str,):
|
||||
type_string="String"
|
||||
else:
|
||||
|
||||
+386
-327
@@ -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
|
||||
from .TemperatureProxyClient import TemperatureProxyClient
|
||||
# TemperatureMonitor imported lazily to avoid circular import issues
|
||||
|
||||
# default, can be set to true from start script
|
||||
debug = False
|
||||
@@ -196,7 +196,9 @@ class DamarisGUI:
|
||||
#to stop queued experiments
|
||||
self.stop_experiment_flag = threading.Event()
|
||||
|
||||
self.temperature_history_start = None
|
||||
# Temperature monitoring
|
||||
self.temp_monitor = None
|
||||
self.temp_controller = None
|
||||
|
||||
exp_script = ""
|
||||
if exp_script_filename is not None and exp_script_filename != "":
|
||||
@@ -251,9 +253,6 @@ class DamarisGUI:
|
||||
"on_display_y_scaling_combobox_changed": self.monitor.display_scaling_changed,
|
||||
"on_display_save_data_as_text_button_clicked": self.monitor.save_display_data_as_text,
|
||||
"on_display_copy_data_to_clipboard_button_clicked": self.monitor.copy_display_data_to_clipboard,
|
||||
"on_display_copy_png_button_clicked": self.monitor.copy_canvas_as_png,
|
||||
"on_display_copy_svg_button_clicked": self.monitor.copy_canvas_as_svg,
|
||||
"on_statusbar_overflow_label_clicked": self.monitor.clear_overflow_indicator,
|
||||
"on_experiment_script_textview_event": self.sw.textviews_modified,
|
||||
"on_main_notebook_switch_page": self.nopevent,
|
||||
"main_window_close": self.nopevent
|
||||
@@ -334,9 +333,6 @@ 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...
|
||||
@@ -525,8 +521,6 @@ class DamarisGUI:
|
||||
logger.error("Experiment is still running or cleaning up!")
|
||||
return
|
||||
|
||||
self.monitor.clear_overflow_indicator( )
|
||||
|
||||
# get config values:
|
||||
config_start = time.time()
|
||||
actual_config = self.config.get( )
|
||||
@@ -687,9 +681,12 @@ 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:
|
||||
@@ -867,14 +864,8 @@ class DamarisGUI:
|
||||
|
||||
still_running = [_f for _f in [ self.si.exp_handling, self.si.res_handling, self.si.back_driver, self.dump_thread ] if _f]
|
||||
if len( still_running ) == 0:
|
||||
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
|
||||
# Stop temperature monitoring when experiment finishes
|
||||
self.stop_temperature_monitoring()
|
||||
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
|
||||
@@ -1197,8 +1188,97 @@ 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 = (
|
||||
@@ -2379,6 +2459,75 @@ class ConfigTab:
|
||||
backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0)
|
||||
self.config_adc_bit_depth_hbox.show_all()
|
||||
|
||||
# Temperature monitoring settings - single line layout
|
||||
self.config_temp_hbox = gtk.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
|
||||
@@ -2485,10 +2634,28 @@ pygobject version %(pygobject)s
|
||||
"data_pool_complib": complib,
|
||||
"data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ),
|
||||
"script_font": self.config_script_font_button.get_font_name( ),
|
||||
"adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int()
|
||||
"adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int(),
|
||||
# Temperature monitoring configuration (only if widgets exist)
|
||||
"enable_temperature_monitoring": self.config_temp_monitoring_checkbutton.get_active() if hasattr(self, 'config_temp_monitoring_checkbutton') else False,
|
||||
"temperature_controller_type": self.config_temp_controller_type_combobox.get_active_text() if hasattr(self, 'config_temp_controller_type_combobox') else "eurotherm",
|
||||
"temperature_device": self.config_temp_device_entry.get_text() if hasattr(self, 'config_temp_device_entry') else "",
|
||||
"temperature_baudrate": self._get_temp_baudrate() if hasattr(self, 'config_temp_baudrate_combobox') else 19200,
|
||||
"temperature_interval": self.config_temp_interval_spinbutton.get_value() if hasattr(self, 'config_temp_interval_spinbutton') else 2.0
|
||||
}
|
||||
ADC_Result.default_bit_depth = actual_config["adc_bit_depth"]
|
||||
return actual_config
|
||||
|
||||
def _get_temp_baudrate(self):
|
||||
"""Get selected baud rate from combobox."""
|
||||
if not hasattr(self, 'config_temp_baudrate_combobox'):
|
||||
return 19200
|
||||
active_text = self.config_temp_baudrate_combobox.get_active_text()
|
||||
if active_text:
|
||||
try:
|
||||
return int(active_text)
|
||||
except ValueError:
|
||||
pass
|
||||
return 19200
|
||||
|
||||
def set( self, config ):
|
||||
if "start_backend" in config:
|
||||
@@ -2521,6 +2688,40 @@ 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( )
|
||||
@@ -2537,6 +2738,82 @@ 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 )
|
||||
@@ -2747,18 +3024,6 @@ 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
|
||||
@@ -2962,27 +3227,13 @@ class MonitorWidgets:
|
||||
self.display_autoscaling_checkbutton = self.xml_gui.get_object( "display_autoscaling_checkbutton" )
|
||||
self.display_statistics_checkbutton = self.xml_gui.get_object( "display_statistics_checkbutton" )
|
||||
|
||||
self.display_descriptions_checkbutton = gtk.CheckButton(label="Descr.")
|
||||
self.display_descriptions_checkbutton = gtk.CheckButton(label="Show Desc.")
|
||||
self.display_descriptions_checkbutton.set_active(True)
|
||||
self.display_descriptions_checkbutton.show()
|
||||
self.display_settings_frame.set_property("n-columns", 5)
|
||||
self.display_settings_frame.attach(self.display_descriptions_checkbutton, 4, 5, 0, 1, gtk.AttachOptions.FILL, 0, 0, 0)
|
||||
self.display_descriptions_checkbutton.connect("toggled", self.display_descriptions_toggled)
|
||||
|
||||
# ADC overflow indicator: "ADC" button, green = OK, red = overflow,
|
||||
# right click opens a menu to reset the state
|
||||
css = gtk.CssProvider( )
|
||||
css.load_from_data( b".adc_ok { background-image: none; background-color: #4caf50; }"
|
||||
b".adc_error { background-image: none; background-color: #e53935; }" )
|
||||
gtk.StyleContext.add_provider_for_screen( gdk.Screen.get_default( ), css, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION )
|
||||
self.overflow_indicator_button = gtk.Button( label="ADC" )
|
||||
self.overflow_indicator_button.set_tooltip_text( "ADC overflow indicator, red if an overflow occurred" )
|
||||
self.overflow_job_ids = [ ]
|
||||
self.overflow_indicator_button.connect( "button-press-event", self.overflow_indicator_button_press )
|
||||
self.display_settings_frame.attach(self.overflow_indicator_button, 4, 5, 1, 2, gtk.AttachOptions.FILL, 0, 0, 0)
|
||||
self.overflow_indicator_button.show( )
|
||||
self.set_overflow_indicator( False )
|
||||
|
||||
# Matplot (Display_Table, 1st Row) --------------------------------------------------------
|
||||
|
||||
# create new plot
|
||||
@@ -3004,6 +3255,9 @@ 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( )
|
||||
@@ -3014,9 +3268,6 @@ class MonitorWidgets:
|
||||
#self.matplot_toolbar.DeleteToolByPos(1)
|
||||
#self.matplot_toolbar.remove(self.matplot_toolbar.get_nth_item(3))
|
||||
self.matplot_toolbar.remove(self.matplot_toolbar.get_nth_item(6))
|
||||
# crosshair and Pan button are mutually exclusive
|
||||
self.matplot_pan_button = self.matplot_toolbar._gtk_ids['Pan']
|
||||
self.matplot_pan_button.connect( 'toggled', self.toggle_pan_button )
|
||||
# now let's add a button to the toolbar
|
||||
self.matplot_cursor_button = gtk.ToggleButton(label='')
|
||||
cursor_icon = GdkPixbuf.Pixbuf.new_from_xpm_data( crosshair_xpm )
|
||||
@@ -3041,16 +3292,6 @@ 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 )
|
||||
@@ -3060,11 +3301,6 @@ class MonitorWidgets:
|
||||
self.display_source_combobox.add_attribute( display_source_cell, 'text', 0 )
|
||||
# GTK3 fix: Prevent blank space from child tree nodes in popup
|
||||
self.display_source_combobox.set_wrap_width(1)
|
||||
# interactive display phase rotation: display only, never stored in HDF
|
||||
self.display_phase_scale = self.xml_gui.get_object( "display_phase_scale" )
|
||||
self.display_phase_value_label = self.xml_gui.get_object( "display_phase_value_label" )
|
||||
self.display_phase = 0
|
||||
self.display_phase_scale.connect( "value-changed", self.display_phase_changed )
|
||||
self.source_list_reset( )
|
||||
self.display_source_path_label = self.xml_gui.get_object( "display_source_path_label" )
|
||||
|
||||
@@ -3094,11 +3330,9 @@ class MonitorWidgets:
|
||||
self.fitresultlines = [ ]
|
||||
self.fit_source_keys = set( )
|
||||
self.clipping_marker = None
|
||||
self.statusbar_overflow_label = self.xml_gui.get_object( "statusbar_overflow_label" )
|
||||
self.source_list_cache = {}
|
||||
|
||||
def source_list_reset( self ):
|
||||
self.display_phase_scale.set_value( 0 )
|
||||
self.display_source_combobox.set_model(None)
|
||||
try:
|
||||
# OPTIMIZATION: Replacing the TreeStore is much faster than clear() for large trees
|
||||
@@ -3467,25 +3701,6 @@ class MonitorWidgets:
|
||||
event = DataPool.Event( DataPool.Event.updated_value, new_data_name )
|
||||
glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE )
|
||||
|
||||
def display_phase_changed( self, widget, data=None ):
|
||||
"""
|
||||
set the display phase in degrees (0-360) for the plotted signal
|
||||
|
||||
Example: monitor.display_phase_scale.set_value(90)
|
||||
"""
|
||||
self.display_phase = widget.get_value( )
|
||||
self.display_phase_value_label.set_label( "%.0f°" % self.display_phase )
|
||||
source = self.displayed_data[ 1 ]
|
||||
if source is not None and isinstance( source, (Accumulation, ADC_Result) ) \
|
||||
and source.get_number_of_channels( ) == 2:
|
||||
# coalesce like the other display updates, dragging the slider
|
||||
# emits one event per pointer motion
|
||||
self.update_counter_lock.acquire( )
|
||||
self.update_counter += 1
|
||||
self.update_counter_lock.release( )
|
||||
glib.idle_add( self.update_display_idle_event, self.displayed_data[ 0 ][ : ],
|
||||
priority=glib.PRIORITY_DEFAULT_IDLE )
|
||||
|
||||
def display_autoscaling_toggled( self, widget, data=None ):
|
||||
self.matplot_axes.set_autoscale_on( self.display_autoscaling_checkbutton.get_active( ) )
|
||||
if self.displayed_data[ 0 ] is not None:
|
||||
@@ -3513,7 +3728,7 @@ class MonitorWidgets:
|
||||
# nothing to save
|
||||
return
|
||||
if not hasattr( data_to_save[ 1 ], "write_to_csv" ):
|
||||
logger.warning( "do not know how to save %s of class/type %s" % (data_to_save[ 0 ], type( data_to_save[ 1 ] )) )
|
||||
log( "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)
|
||||
@@ -3573,103 +3788,6 @@ 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_pan_button(self, button):
|
||||
"""Deactivate the crosshair button when the Pan mode is activated."""
|
||||
if button.get_active( ) and self.matplot_cursor_button.get_active( ):
|
||||
self.matplot_cursor_button.set_active( False )
|
||||
|
||||
def toggle_cursor(self, widget):
|
||||
"""Toggle matplotlib crosshair cursor"""
|
||||
if widget.get_active():
|
||||
@@ -3678,9 +3796,7 @@ class MonitorWidgets:
|
||||
self.matplot_cursor = CrosshairCursor(self.matplot_axes,
|
||||
color='red', linewidth=1)
|
||||
self.matplot_canvas.draw()
|
||||
# leave the Pan mode, the two buttons are mutually exclusive
|
||||
if self.matplot_pan_button.get_active( ):
|
||||
self.matplot_pan_button.set_active( False )
|
||||
# TODO: Deactivate the pane button
|
||||
|
||||
else:
|
||||
# Deactivate cursor
|
||||
@@ -3694,6 +3810,40 @@ 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)
|
||||
@@ -3704,20 +3854,25 @@ 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)
|
||||
|
||||
logger.info("Figure copied to clipboard as SVG")
|
||||
log("Figure copied to clipboard as SVG")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error copying figure as SVG: %s", e)
|
||||
log(f"Error copying figure as SVG: {e}")
|
||||
|
||||
def copy_display_data_to_clipboard( self, widget, data=None ):
|
||||
"""
|
||||
@@ -3725,97 +3880,38 @@ 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:
|
||||
# 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 )
|
||||
# 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()
|
||||
|
||||
# Copy to clipboard
|
||||
clipboard = gtk.Clipboard.get( gdk.SELECTION_CLIPBOARD )
|
||||
clipboard.set_image( pixbuf )
|
||||
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()
|
||||
|
||||
logger.info("Figure copied to clipboard as PNG image")
|
||||
log("Figure copied to clipboard as PNG image")
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error copying figure to clipboard: %s", e)
|
||||
|
||||
def set_overflow_indicator( self, clipped ):
|
||||
"""
|
||||
Set the background color of the ADC indicator button: green if
|
||||
clipped is False, red if clipped is True.
|
||||
|
||||
Example: monitor.set_overflow_indicator(True)
|
||||
"""
|
||||
style = self.overflow_indicator_button.get_style_context( )
|
||||
style.remove_class( "adc_ok" )
|
||||
style.remove_class( "adc_error" )
|
||||
style.add_class( "adc_error" if clipped else "adc_ok" )
|
||||
|
||||
def overflow_indicator_button_press( self, widget, event ):
|
||||
"""
|
||||
ADC indicator button press handler: left click shows a dialog with
|
||||
the list of job ids where an ADC overflow occurred, right click
|
||||
opens a menu to reset the overflow state.
|
||||
|
||||
Example: monitor.overflow_indicator_button_press(button, event)
|
||||
"""
|
||||
if event.button == 1:
|
||||
if self.overflow_job_ids:
|
||||
text = "\n".join( str( job_id ) for job_id in self.overflow_job_ids )
|
||||
else:
|
||||
text = "No ADC overflow recorded."
|
||||
dialog = gtk.MessageDialog(parent=self.main_window,
|
||||
type=gtk.MessageType.WARNING,
|
||||
flags=gtk.DialogFlags.MODAL,
|
||||
buttons=gtk.ButtonsType.OK)
|
||||
dialog.set_markup("ADC OVERFLOW in jobs:")
|
||||
dialog.format_secondary_text( text )
|
||||
dialog.run( )
|
||||
dialog.destroy( )
|
||||
elif event.button == 3:
|
||||
menu = gtk.Menu( )
|
||||
item = gtk.MenuItem( label="Reset overflow state" )
|
||||
item.connect("activate", self.clear_overflow_indicator)
|
||||
menu.append( item )
|
||||
menu.show_all( )
|
||||
menu.popup_at_pointer( event )
|
||||
return False
|
||||
|
||||
def clear_overflow_indicator( self, widget=None, event=None ):
|
||||
"""
|
||||
Reset handler for the ADC overflow warning: sets the indicator back to
|
||||
green and clears the status bar warning, without starting a new
|
||||
experiment.
|
||||
|
||||
Example: monitor.clear_overflow_indicator()
|
||||
"""
|
||||
self.statusbar_overflow_label.set_text( "" )
|
||||
self.set_overflow_indicator( False )
|
||||
self.overflow_job_ids = [ ]
|
||||
return False
|
||||
log("Error copying figure to clipboard: %s" % str(e))
|
||||
|
||||
##################### functions to feed display
|
||||
def clear_display( self ):
|
||||
@@ -3849,7 +3945,7 @@ class MonitorWidgets:
|
||||
self.measurementresultline = None
|
||||
elif self.measurementresultline is not None:
|
||||
# clear line plot
|
||||
self.matplot_axes.lines.remove( self.measurementresultline[ 0 ] )
|
||||
self.measurementresultline[ 0 ].remove( )
|
||||
self.measurementresultline = None
|
||||
if hasattr(self, 'matplot_cursor') and self.matplot_cursor is not None:
|
||||
self.matplot_cursor.disconnect_events()
|
||||
@@ -3915,41 +4011,11 @@ class MonitorWidgets:
|
||||
return
|
||||
if isinstance( in_result, Accumulation ) or isinstance( in_result, ADC_Result ) or isinstance( in_result, TemperatureResult ):
|
||||
|
||||
plot_result = in_result
|
||||
if self.display_phase and isinstance( in_result, (Accumulation, ADC_Result) ) \
|
||||
and in_result.get_number_of_channels( ) == 2:
|
||||
# display only: plot a rotated copy, the pool object itself and
|
||||
# any HDF storage keep the unrotated data. The rotation reads
|
||||
# the raw channel arrays, so take the snapshot while holding the
|
||||
# lock that the result scripts use to update them in place.
|
||||
in_result.lock.acquire( )
|
||||
try:
|
||||
plot_result = in_result.phase( self.display_phase )
|
||||
finally:
|
||||
in_result.lock.release( )
|
||||
if isinstance( plot_result, Accumulation ):
|
||||
# the copy's y_square is not rotated, so its own error
|
||||
# calculation would produce nonsense limits
|
||||
plot_result.use_error = False
|
||||
xmin = in_result.get_xmin( )
|
||||
xmax = in_result.get_xmax( )
|
||||
|
||||
# error bars are read from the pool object, i.e. they are not
|
||||
# phase rotated, and the rotated copy would derive them from
|
||||
# mismatched y_square anyway
|
||||
show_errors = (self.display_statistics_checkbutton.get_active( ) and
|
||||
in_result.uses_statistics( ) and in_result.ready_for_drawing_error( ))
|
||||
|
||||
xmin = plot_result.get_xmin( )
|
||||
xmax = plot_result.get_xmax( )
|
||||
|
||||
ymin = plot_result.get_ymin( )
|
||||
ymax = plot_result.get_ymax( )
|
||||
if plot_result is not in_result and show_errors:
|
||||
# the plot draws the unrotated pool errors on the rotated data,
|
||||
# so the axis limits must use that same combination
|
||||
for i in range( plot_result.get_number_of_channels( ) ):
|
||||
err = in_result.get_yerr( i )
|
||||
ymin = min( ymin, (plot_result.get_ydata( i ) - err).min( ) )
|
||||
ymax = max( ymax, (plot_result.get_ydata( i ) + err).max( ) )
|
||||
ymin = in_result.get_ymin( )
|
||||
ymax = in_result.get_ymax( )
|
||||
|
||||
# Initial rescaling needed?
|
||||
if self.__rescale:
|
||||
@@ -3960,7 +4026,7 @@ class MonitorWidgets:
|
||||
self.matplot_axes.set_xscale( "linear" )
|
||||
self.matplot_axes.set_xlim(xmin, xmax)
|
||||
elif x_scale == "log10":
|
||||
xminpos = plot_result.get_xminpos( )
|
||||
xminpos = in_result.get_xminpos( )
|
||||
self.matplot_axes.set_xscale("log", base=10, nonpositive="mask")
|
||||
self.matplot_axes.set_xlim(xminpos, xmax)
|
||||
elif x_scale == "ppm":
|
||||
@@ -3974,7 +4040,7 @@ class MonitorWidgets:
|
||||
self.matplot_axes.set_yscale( "linear" )
|
||||
self.matplot_axes.set_ylim(ymin, ymax)
|
||||
elif y_scale == "log10":
|
||||
yminpos = plot_result.get_yminpos()
|
||||
yminpos = in_result.get_yminpos()
|
||||
self.matplot_axes.set_yscale("log", base=10, nonpositive="mask")
|
||||
self.matplot_axes.set_ylim( yminpos, ymax )
|
||||
self.__rescale = False
|
||||
@@ -4014,14 +4080,14 @@ class MonitorWidgets:
|
||||
ylim_max > ymax + 0.2 * ydiff or ylim_min < ymin - 0.2 * ydiff):
|
||||
self.matplot_axes.set_ylim(ymin, ymax)
|
||||
|
||||
xdata = plot_result.get_xdata( )
|
||||
chans = plot_result.get_number_of_channels( )
|
||||
xdata = in_result.get_xdata( )
|
||||
chans = in_result.get_number_of_channels( )
|
||||
data = [ ]
|
||||
colors = [ (0, 0, 0.8, 1), (0.7, 0, 0, 1), (0, 0.7, 0, 1), (0.7, 0.5, 0, 1),
|
||||
(0, 0, 0, 1) ] # rgba tuples: blue, red, green, yellow
|
||||
|
||||
for i in range( chans ):
|
||||
data.append( plot_result.get_ydata( i ) )
|
||||
data.append( in_result.get_ydata( i ) )
|
||||
|
||||
moving_average = data_slice = None
|
||||
if max_points_to_display > 0 and len( xdata ) > max_points_to_display:
|
||||
@@ -4050,7 +4116,8 @@ class MonitorWidgets:
|
||||
self.graphen[ i ].set_data( xdata, data[ i ] )
|
||||
|
||||
# Statistics activated?
|
||||
if show_errors:
|
||||
if (self.display_statistics_checkbutton.get_active( ) and
|
||||
in_result.uses_statistics( ) and in_result.ready_for_drawing_error( )):
|
||||
for i in range( chans ):
|
||||
err = in_result.get_yerr( i )
|
||||
if moving_average is not None:
|
||||
@@ -4123,14 +4190,6 @@ class MonitorWidgets:
|
||||
color="red", fontsize=20, fontweight="bold",
|
||||
ha="center", va="top",
|
||||
bbox=dict(facecolor='white', alpha=0.7, edgecolor='red'))
|
||||
# the warning is only justified for results with a valid job_id;
|
||||
# results without job id (e.g. backend errors) only get the plot marker
|
||||
if getattr(in_result, "job_id", None) is not None:
|
||||
self.set_overflow_indicator( True )
|
||||
if in_result.job_id not in self.overflow_job_ids:
|
||||
self.overflow_job_ids.append( in_result.job_id )
|
||||
self.statusbar_overflow_label.set_markup( '<span foreground="red" weight="bold">ADC OVERFLOW</span>' )
|
||||
logger.warning( "ADC overflow detected in job %s", in_result.job_id )
|
||||
elif self.clipping_marker is not None:
|
||||
try:
|
||||
self.clipping_marker.remove()
|
||||
@@ -4146,17 +4205,17 @@ class MonitorWidgets:
|
||||
|
||||
# remove lines and error bars
|
||||
if self.measurementresultgraph is not None:
|
||||
self.matplot_axes.lines.remove( self.measurementresultgraph[ 0 ] )
|
||||
self.measurementresultgraph[ 0 ].remove( )
|
||||
# remove caps
|
||||
for l in self.measurementresultgraph[ 1 ]:
|
||||
self.matplot_axes.lines.remove( l )
|
||||
l.remove( )
|
||||
# and columns
|
||||
for l in self.measurementresultgraph[ 2 ]:
|
||||
self.matplot_axes.collections.remove( l )
|
||||
l.remove( )
|
||||
self.measurementresultgraph = None
|
||||
|
||||
if self.measurementresultline is not None:
|
||||
self.matplot_axes.lines.remove( self.measurementresultline[ 0 ] )
|
||||
self.measurementresultline[ 0 ].remove( )
|
||||
self.measurementresultline = None
|
||||
|
||||
# remove fit overlay lines
|
||||
@@ -4244,13 +4303,13 @@ class MonitorWidgets:
|
||||
|
||||
# finally decide about x log plot
|
||||
if x_scale == "log10" and xmin > 0:
|
||||
self.matplot_axes.set_xscale( "log", basex=10.0 )
|
||||
self.matplot_axes.set_xscale( "log", base=10.0 )
|
||||
self.matplot_axes.fmt_xdata = lambda x: "%g" % x
|
||||
#elif x_scale=="log" and xmin>0:
|
||||
# e scaling implementation not really useful
|
||||
# self.matplot_axes.set_xscale("log", basex=numpy.e)
|
||||
if y_scale == "log10" and ymin > 0:
|
||||
self.matplot_axes.set_yscale( "log", basex=10.0 )
|
||||
self.matplot_axes.set_yscale( "log", base=10.0 )
|
||||
self.matplot_axes.fmt_ydata = lambda x: "%g" % x
|
||||
|
||||
self.__rescale = False
|
||||
|
||||
@@ -135,7 +135,8 @@ class ResultReader:
|
||||
elif t == "Complex":
|
||||
self.result_description[k]=complex(v)
|
||||
elif t == "Boolean":
|
||||
self.result_description[k]=bool(v)
|
||||
# bool("False") would be True, compare the text instead
|
||||
self.result_description[k]=(v == "True")
|
||||
elif t == "String":
|
||||
self.result_description[k]=v
|
||||
else:
|
||||
@@ -502,7 +503,8 @@ class ResultReader:
|
||||
elif t == "Complex":
|
||||
self.result_description[k]=complex(v)
|
||||
elif t == "Boolean":
|
||||
self.result_description[k]=bool(v)
|
||||
# bool("False") would be True, compare the text instead
|
||||
self.result_description[k]=(v == "True")
|
||||
elif t == "String":
|
||||
self.result_description[k]=v
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# -*- 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()
|
||||
@@ -1,53 +0,0 @@
|
||||
"""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
|
||||
@@ -37,12 +37,6 @@
|
||||
<property name="step-increment">1</property>
|
||||
<property name="page-increment">1</property>
|
||||
</object>
|
||||
<object class="GtkAdjustment" id="display_phase_adjustment">
|
||||
<property name="upper">360</property>
|
||||
<property name="value">0</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="page-increment">10</property>
|
||||
</object>
|
||||
<object class="GtkMenu" id="documentation_menu">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
@@ -1102,47 +1096,6 @@ Public License instead of this License.
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="display_phase_label">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="label" translatable="yes"> Phase:</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScale" id="display_phase_scale">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="width-request">150</property>
|
||||
<property name="draw-value">False</property>
|
||||
<property name="digits">0</property>
|
||||
<property name="round-digits">0</property>
|
||||
<property name="adjustment">display_phase_adjustment</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="display_phase_value_label">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="label" translatable="yes">0°</property>
|
||||
<property name="width-chars">4</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">4</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="y-options"/>
|
||||
@@ -1238,11 +1191,10 @@ Public License instead of this License.
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkCheckButton" id="display_autoscaling_checkbutton">
|
||||
<property name="label" translatable="yes">Autoscale</property>
|
||||
<property name="label" translatable="yes">Auto-Scaling</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="tooltip-text" translatable="yes">Rescale graph if new ymax is larger than the old one or shrinked more than 20%</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="draw-indicator">True</property>
|
||||
@@ -1259,11 +1211,10 @@ Public License instead of this License.
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkCheckButton" id="display_statistics_checkbutton">
|
||||
<property name="label" translatable="yes">Statistics</property>
|
||||
<property name="label" translatable="yes">Show Statistics</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="tooltip-text" translatable="yes">Plot error-bars if result to plot uses statistic</property>
|
||||
<property name="use-underline">True</property>
|
||||
<property name="draw-indicator">True</property>
|
||||
@@ -1463,56 +1414,10 @@ Public License instead of this License.
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<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"><span foreground="#0000ff">PNG</span></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>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<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"><span foreground="#0000ff">SVG</span></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>
|
||||
<placeholder/>
|
||||
</child>
|
||||
<child>
|
||||
<placeholder/>
|
||||
@@ -2392,7 +2297,7 @@ Public License instead of this License.
|
||||
<object class="GtkTable" id="statusbar_table">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="n-columns">9</property>
|
||||
<property name="n-columns">8</property>
|
||||
<child>
|
||||
<object class="GtkVSeparator" id="vseparator1">
|
||||
<property name="visible">True</property>
|
||||
@@ -2479,29 +2384,6 @@ Public License instead of this License.
|
||||
<property name="y-options"/>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkEventBox" id="statusbar_overflow_eventbox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="above-child">True</property>
|
||||
<signal name="button-press-event" handler="on_statusbar_overflow_label_clicked" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkLabel" id="statusbar_overflow_label">
|
||||
<property name="visible">True</property>
|
||||
<property name="can-focus">False</property>
|
||||
<property name="label" translatable="yes"/>
|
||||
<property name="use-markup">True</property>
|
||||
<property name="tooltip-text" translatable="yes">ADC overflow detected. Click to acknowledge.</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="left-attach">7</property>
|
||||
<property name="right-attach">8</property>
|
||||
<property name="x-options"/>
|
||||
<property name="y-options"/>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="label33">
|
||||
<property name="visible">True</property>
|
||||
@@ -2510,8 +2392,8 @@ Public License instead of this License.
|
||||
<property name="xalign">0</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="left-attach">8</property>
|
||||
<property name="right-attach">9</property>
|
||||
<property name="left-attach">7</property>
|
||||
<property name="right-attach">8</property>
|
||||
<property name="y-options"/>
|
||||
</packing>
|
||||
</child>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"""
|
||||
Unified logging for DAMARIS.
|
||||
|
||||
Provides these components:
|
||||
|
||||
Provides:
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
@@ -0,0 +1,162 @@
|
||||
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)
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- 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
|
||||
@@ -0,0 +1,126 @@
|
||||
# -*- coding: iso-8859-1 -*-
|
||||
"""
|
||||
Simulated temperature controller for testing.
|
||||
|
||||
Provides a temperature controller that simulates real temperature readings
|
||||
with configurable parameters including sinusoidal variation, noise, and missing values.
|
||||
"""
|
||||
|
||||
import time
|
||||
import math
|
||||
import random
|
||||
import threading
|
||||
|
||||
from .temperature import TemperatureController, register_controller
|
||||
|
||||
|
||||
class SimulatedTemperatureController(TemperatureController):
|
||||
"""
|
||||
A simulated temperature controller that generates realistic temperature readings.
|
||||
|
||||
This controller is useful for testing and development without requiring
|
||||
physical hardware. It simulates temperature readings with:
|
||||
- Base temperature around 290K (17C)
|
||||
- Sinusoidal variation with amplitude of 10K
|
||||
- 5% random noise
|
||||
- 10% probability of missing values (returns previous value)
|
||||
- 60 second period for the sine wave
|
||||
|
||||
Attributes:
|
||||
base_temp: Base temperature in Kelvin (default: 290.0)
|
||||
amplitude: Amplitude of sinusoidal variation in Kelvin (default: 10.0)
|
||||
period: Period of sine wave in seconds (default: 60.0)
|
||||
noise_level: Fraction of random noise (default: 0.05 = 5%)
|
||||
missing_probability: Probability of missing value (default: 0.10 = 10%)
|
||||
"""
|
||||
|
||||
def __init__(self, base_temp: float = 290.0, amplitude: float = 10.0,
|
||||
period: float = 60.0, noise_level: float = 0.05,
|
||||
missing_probability: float = 0.10):
|
||||
"""
|
||||
Initialize the simulated temperature controller.
|
||||
|
||||
Args:
|
||||
base_temp: Base temperature in Kelvin (default: 290.0)
|
||||
amplitude: Amplitude of sinusoidal variation in Kelvin (default: 10.0)
|
||||
period: Period of sine wave in seconds (default: 60.0)
|
||||
noise_level: Fraction of random noise (default: 0.05 = 5%)
|
||||
missing_probability: Probability of missing value (default: 0.10 = 10%)
|
||||
"""
|
||||
self.base_temp = float(base_temp)
|
||||
self.amplitude = float(amplitude)
|
||||
self.period = float(period)
|
||||
self.noise_level = float(noise_level)
|
||||
self.missing_probability = float(missing_probability)
|
||||
|
||||
self._start_time = time.time()
|
||||
self._lock = threading.RLock()
|
||||
self._setpoint = self.base_temp
|
||||
self._last_temp = self.base_temp
|
||||
self._last_reading_time = self._start_time
|
||||
|
||||
def get_temperature(self) -> float:
|
||||
"""
|
||||
Get simulated current temperature in Kelvin.
|
||||
|
||||
Returns:
|
||||
float: Simulated temperature in Kelvin
|
||||
"""
|
||||
with self._lock:
|
||||
elapsed = time.time() - self._start_time
|
||||
current_time = time.time()
|
||||
|
||||
# Calculate ideal sinusoidal temperature
|
||||
temp = self.base_temp + self.amplitude * math.sin(2 * math.pi * elapsed / self.period)
|
||||
|
||||
# Add 5% random noise
|
||||
noise = temp * self.noise_level * (random.random() * 2 - 1) # -5% to +5%
|
||||
temp += noise
|
||||
|
||||
# 10% chance of missing value (return last good value)
|
||||
if random.random() < self.missing_probability:
|
||||
temp = self._last_temp
|
||||
else:
|
||||
self._last_temp = temp
|
||||
self._last_reading_time = current_time
|
||||
|
||||
return temp
|
||||
|
||||
def get_setpoint(self) -> float:
|
||||
"""
|
||||
Get the target setpoint temperature in Kelvin.
|
||||
|
||||
Returns:
|
||||
float: Target temperature in Kelvin
|
||||
"""
|
||||
with self._lock:
|
||||
return self._setpoint
|
||||
|
||||
def set_setpoint(self, temperature: float) -> bool:
|
||||
"""
|
||||
Set the target temperature setpoint.
|
||||
|
||||
Args:
|
||||
temperature: Target temperature in Kelvin
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
self._setpoint = float(temperature)
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
"""Reset the simulation to initial state."""
|
||||
with self._lock:
|
||||
self._start_time = time.time()
|
||||
self._last_temp = self.base_temp
|
||||
self._last_reading_time = self._start_time
|
||||
|
||||
|
||||
# Register the simulated controller with the factory
|
||||
register_controller('simulated', SimulatedTemperatureController)
|
||||
+1
-3
@@ -11,10 +11,10 @@ def result():
|
||||
accu = Accumulation(error=False)
|
||||
|
||||
for num,ts in enumerate(results):
|
||||
data["accu"] = accu
|
||||
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()
|
||||
@@ -31,9 +31,7 @@ def result():
|
||||
data["test/fit"] = fit
|
||||
data["test"] = r
|
||||
accu+=ts
|
||||
data["accu"] = accu+0
|
||||
print(accu)
|
||||
data["accu_fft"] = accu.fft().autophase()
|
||||
accu.write_to_hdf(h5, "/", "name", "title")
|
||||
try:
|
||||
h5.close()
|
||||
|
||||
@@ -30,21 +30,6 @@ 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])
|
||||
|
||||
@@ -33,14 +33,6 @@ 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])
|
||||
@@ -360,20 +352,6 @@ 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()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from damaris.experiments.Experiment import Experiment
|
||||
|
||||
|
||||
def phase_of_state(state):
|
||||
"extracts the phase value from a state's XML content"
|
||||
match = re.search(r'phase="([-0-9.]+)"', state.content)
|
||||
assert match, "no phase in state: %s" % state.content
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
class TestExperimentPhase(unittest.TestCase):
|
||||
def test_set_phase_negative_normalized(self):
|
||||
"""Test that set_phase() normalizes negative phases to [0, 360)."""
|
||||
e = Experiment()
|
||||
for degrees, expected in ((-90, 270.0), (-45.5, 314.5),
|
||||
(-270, 90.0), (-360, 0.0)):
|
||||
e.set_phase(degrees)
|
||||
self.assertEqual(phase_of_state(e.state_list.list[-1]), expected)
|
||||
|
||||
def test_set_phase_positive_unchanged(self):
|
||||
"""Test that set_phase() passes positive phases through unchanged."""
|
||||
e = Experiment()
|
||||
for degrees, expected in ((0, 0.0), (90, 90.0), (270, 270.0),
|
||||
(45.5, 45.5)):
|
||||
e.set_phase(degrees)
|
||||
self.assertEqual(phase_of_state(e.state_list.list[-1]), expected)
|
||||
|
||||
def test_set_frequency_negative_phase_normalized(self):
|
||||
"""Test that set_frequency() normalizes a negative phase."""
|
||||
e = Experiment()
|
||||
e.set_frequency(85e6, -90)
|
||||
state = e.state_list.list[-1]
|
||||
self.assertEqual(phase_of_state(state), 270.0)
|
||||
self.assertIn('f="85000000.000000"', state.content)
|
||||
|
||||
def test_rf_pulse_negative_phase_normalized(self):
|
||||
"""Test that rf_pulse() normalizes a negative pulse phase."""
|
||||
e = Experiment(gating=1e-6, rf_sources=[1], rf_gates=[2])
|
||||
e.rf_pulse(length=1e-6, phase=-90, source=0)
|
||||
self.assertEqual(phase_of_state(e.state_list.list[0]), 270.0)
|
||||
|
||||
def test_numpy_scalars_in_description(self):
|
||||
"""Test that description items with numpy scalars (e.g. from lin_range)
|
||||
are written as plain numbers so the result script can parse them again."""
|
||||
import numpy as np
|
||||
from damaris.tools.ranges import lin_range
|
||||
e = Experiment()
|
||||
e.set_description("tau", lin_range(1e-6, 3e-6, 1e-6)[0])
|
||||
e.set_description("n", np.int64(42))
|
||||
xml = e.write_xml_string()
|
||||
items = {key: (typ, text)
|
||||
for key, typ, text in
|
||||
re.findall(r'<item key="([^"]+)" type="([^"]+)">([^<]*)</item>', xml)}
|
||||
self.assertEqual(items["tau"][0], "Float")
|
||||
self.assertEqual(items["n"][0], "Int")
|
||||
self.assertEqual(float(items["tau"][1]), 1e-6)
|
||||
self.assertEqual(int(items["n"][1]), 42)
|
||||
|
||||
def test_numpy_scalar_state_times(self):
|
||||
"""Test that numpy scalar times do not end up as np.float64(...) in the XML."""
|
||||
import numpy as np
|
||||
e = Experiment()
|
||||
e.wait(np.float64(1e-6))
|
||||
e.state_start(np.float64(2e-6))
|
||||
xml = e.write_xml_string()
|
||||
self.assertNotIn("np.float64", xml)
|
||||
self.assertIn('<state time="1e-06"/>', xml)
|
||||
self.assertIn('<state time="2e-06">', xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,93 +0,0 @@
|
||||
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()
|
||||
Reference in new issue
Block a user