12 Commits
Author SHA1 Message Date
markusro 801e31e84f Release v0.33 2026-10-23 11:13:39 +02:00
markusro 5f558e4f03 make temperature proxy host and port configurable in the configuration tab 2026-10-23 11:11:18 +02:00
markusro f562255c75 merge local fixes 2026-10-23 10:13:32 +02:00
markusro 42a4f9e2b9 Release v0.32
Build Debian Packages / build (bookworm, debian12) (push) Successful in 11m50s
Build Debian Packages / build (trixie, debian13) (push) Successful in 12m24s
2026-10-22 16:52:02 +02:00
markusro ed5ddde2e3 fix removal of plot artists: Axes.lines/collections are read-only ArtistLists since matplotlib 3.5 2026-10-22 16:51:20 +02:00
markusro f86dafeda7 version bump because of merge 2026-10-19 09:31:52 +02:00
markusro 9e42205a4d Merge branch 'main' of https://gitea.pkm.physik.tu-darmstadt.de/IPKM/python3-damaris
Build Debian Packages / build (bookworm, debian12) (push) Successful in 9m20s
Build Debian Packages / build (trixie, debian13) (push) Successful in 9m22s
2026-10-19 09:19:39 +02:00
markusro 1f6763f9de fix log scaling: replace removed basex= keyword with base= in set_xscale/set_yscale 2026-10-16 13:53:56 +02:00
markusro 6668ac5b2b fix removal of plot artists: Axes.lines/collections are read-only ArtistLists since matplotlib 3.5 2026-10-16 13:50:25 +02:00
markusro ee453c03a7 fix boolean parsing in ResultReader description
bool("False") is True, so stored False description values were read back
as True. Compare the written text against "True" instead.
2026-10-16 12:17:44 +02:00
markusro 1fb56e20f9 fix serialization of numpy scalars in job XML under numpy 2
numpy 2 changed repr(np.float64(1e-06)) to 'np.float64(1e-06)', so values
taken from lin_range() ended up unparsable in the job file: the result
script failed with "could not convert string to float". Description items
and state times are now converted to builtin types before writing.
2026-10-16 12:16:16 +02:00
markusro ef11824f8b Experiment.set_phase(): normalize negative phases to [0, 360)
The phase was passed to the backend job file verbatim, so negative
values (e.g. from rf_pulse(phase=-90)) reached the backend unchanged.
set_phase(), set_frequency() and thus rf_pulse() now write the phase
normalized with phase % 360. Add unittests for the normalization.
2026-10-16 11:31:37 +02:00
9 changed files with 172 additions and 31 deletions

No files matched your search

+13
View File
@@ -1,3 +1,16 @@
python3-damaris (0.33) stable; urgency=medium
* restore numpy scalar job XML serialization, broken since v0.28: lin_range and log_range values were written as unparsable np.float64(...)
* make temperature proxy host and port configurable in the configuration tab
-- Markus Rosenstihl <markus.rosenstihl@pkm.tu-darmstadt.de> Fri, 23 Oct 2026 11:13:39 +0200
python3-damaris (0.32) stable; urgency=medium
* fix removal of plot artists: Axes.lines/collections are read-only ArtistLists since matplotlib 3.5
-- Markus Rosenstihl <markus.rosenstihl@pkm.tu-darmstadt.de> Thu, 22 Oct 2026 16:51:50 +0200
python3-damaris (0.31) stable; urgency=medium
* added phasing slider
+3 -2
View File
@@ -51,12 +51,13 @@ Following is a short description of the options:
10. Filename of the data pool file
11. Write interval after which the data pool is rewritten
12. Compression settings
13. Authors, License and version numbers of used components
13. Host and port of the temperature proxy service
14. 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.
DAMARIS automatically retrieves one temperature history for the complete experiment from the ``tempproxy`` service at ``http://<host>:<port>/v1/readings`` after the experiment finishes. Host and port are configured in the Configuration tab, the defaults are ``127.0.0.1`` and ``18007``. 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
=================
+1 -1
View File
@@ -15,7 +15,7 @@
## module contents
#
__version__ = "0.31"
__version__ = "0.33"
__all__=["experiments", "data", "gui"]
import sys
+10 -9
View File
@@ -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:
+39 -9
View File
@@ -871,7 +871,10 @@ class DamarisGUI:
temperature_history_start = self.temperature_history_start
self.temperature_history_start = None
self.data.pop("temperature", None)
temperature = TemperatureProxyClient().get_history(
proxy_config = self.config.get( )
temperature = TemperatureProxyClient(
host=proxy_config["tempproxy_host"],
port=proxy_config["tempproxy_port"]).get_history(
temperature_history_start, time.time())
if temperature is not None:
self.data["temperature"] = temperature
@@ -2379,6 +2382,27 @@ class ConfigTab:
backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0)
self.config_adc_bit_depth_hbox.show_all()
# temperature proxy settings
self.config_tempproxy_hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL, spacing=6)
self.config_tempproxy_host_label = gtk.Label(label="Temp proxy host:")
self.config_tempproxy_host_entry = gtk.Entry()
self.config_tempproxy_host_entry.set_text("127.0.0.1")
self.config_tempproxy_host_entry.set_tooltip_text("Host of the tempproxy service reading the temperature controller")
self.config_tempproxy_host_entry.connect("key-press-event", self.config_keypress)
self.config_tempproxy_port_label = gtk.Label(label="port:")
self.config_tempproxy_port_spinbutton = gtk.SpinButton.new_with_range(1, 65535, 1)
self.config_tempproxy_port_spinbutton.set_value(18007)
self.config_tempproxy_port_spinbutton.set_tooltip_text("Port of the tempproxy service, /v1/readings is requested there")
self.config_tempproxy_port_spinbutton.connect("key-press-event", self.config_keypress)
self.config_tempproxy_hbox.pack_start(self.config_tempproxy_host_label, False, False, 0)
self.config_tempproxy_hbox.pack_start(self.config_tempproxy_host_entry, False, False, 0)
self.config_tempproxy_hbox.pack_start(self.config_tempproxy_port_label, False, False, 0)
self.config_tempproxy_hbox.pack_start(self.config_tempproxy_port_spinbutton, False, False, 0)
frontend_vbox = self.xml_gui.get_object("vbox7")
if frontend_vbox:
frontend_vbox.pack_start(self.config_tempproxy_hbox, False, False, 0)
self.config_tempproxy_hbox.show_all()
# insert version informations
components_text = """
operating system %(os)s
@@ -2485,7 +2509,9 @@ 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(),
"tempproxy_host": self.config_tempproxy_host_entry.get_text( ),
"tempproxy_port": self.config_tempproxy_port_spinbutton.get_value_as_int( )
}
ADC_Result.default_bit_depth = actual_config["adc_bit_depth"]
return actual_config
@@ -2520,6 +2546,10 @@ pygobject version %(pygobject)s
ADC_Result.default_bit_depth = int(config["adc_bit_depth"])
except (ValueError, TypeError):
pass
if "tempproxy_host" in config:
self.config_tempproxy_host_entry.set_text( str( config[ "tempproxy_host" ] ) )
if "tempproxy_port" in config:
self.config_tempproxy_port_spinbutton.set_value( float( config[ "tempproxy_port" ] ) )
if "data_pool_complib" in config:
# find combo-box entry and make it active...
@@ -3849,7 +3879,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()
@@ -4146,17 +4176,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 +4274,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
+4 -2
View File
@@ -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:
+12 -5
View File
@@ -1,7 +1,7 @@
"""Read experiment temperature history from the local tempproxy service.
"""Read experiment temperature history from the tempproxy service.
Example:
client = TemperatureProxyClient()
client = TemperatureProxyClient() # defaults to http://127.0.0.1:18007
temperature = client.get_history(start_epoch, end_epoch)
"""
@@ -18,11 +18,18 @@ logger = logging.getLogger(__name__)
class TemperatureProxyClient:
"""Retrieve experiment-bounded readings from ``localhost:8000``."""
"""Retrieve experiment-bounded readings from a configurable tempproxy host/port."""
URL = "http://localhost:8000/v1/readings"
TIMEOUT = 5
def __init__(self, host: str = "127.0.0.1", port: int = 18007):
"""Create a client for ``http://<host>:<port>/v1/readings``.
Example:
client = TemperatureProxyClient(host="192.168.1.5", port=18007)
"""
self.url = f"http://{host}:{port}/v1/readings"
def get_history(self, start_epoch: float, end_epoch: float) -> TemperatureResult | None:
"""Return temperature history relative to the experiment start, or ``None``.
@@ -33,7 +40,7 @@ class TemperatureProxyClient:
temperature = TemperatureProxyClient().get_history(1000.0, 1060.0)
"""
query = urlencode({"start": start_epoch, "end": end_epoch})
url = f"{self.URL}?{query}"
url = f"{self.url}?{query}"
try:
with urlopen(url, timeout=self.TIMEOUT) as response:
+75
View File
@@ -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()
+15 -3
View File
@@ -22,7 +22,7 @@ class TestTemperatureProxyClient(unittest.TestCase):
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):
def test_get_history_uses_default_endpoint_and_converts_readings(self, mock_urlopen):
mock_urlopen.return_value = self.response({
"readings": [
{"t": 101.5, "pv": 293.15},
@@ -35,13 +35,25 @@ class TestTemperatureProxyClient(unittest.TestCase):
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.netloc, "127.0.0.1:18007")
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_custom_host_and_port_are_used(self, mock_urlopen):
mock_urlopen.return_value = self.response({"readings": [{"t": 101.0, "pv": 293.0}]})
client = TemperatureProxyClient(host="proxy.example.org", port=9000)
client.get_history(100.0, 110.0)
args, _ = mock_urlopen.call_args
parsed_url = urlparse(args[0])
self.assertEqual(parsed_url.netloc, "proxy.example.org:9000")
self.assertEqual(parsed_url.path, "/v1/readings")
@patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_empty_readings_return_none(self, mock_urlopen):
mock_urlopen.return_value = self.response({"readings": []})
@@ -62,7 +74,7 @@ class TestTemperatureProxyClient(unittest.TestCase):
OSError("connection refused"),
TimeoutError("request timed out"),
http.client.BadStatusLine("not an HTTP response"),
HTTPError(TemperatureProxyClient.URL, 500, "server error", {}, None),
HTTPError(self.client.url, 500, "server error", {}, None),
)
for failure in failures:
with self.subTest(failure=failure):