12 Commits
Author SHA1 Message Date
markusro 4dfd564a9b modernize threading idioms 2026-10-24 22:23:59 +02:00
markusro 0d2d3122e3 fix regressions from phasing commit: use lock context manager and restore ADC overflow warnings 2026-10-23 23:45:42 +02:00
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 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
12 changed files with 186 additions and 50 deletions

No files matched your search

+13
View File
@@ -1,3 +1,16 @@
python3-damaris (0.34) stable; urgency=medium
* internal cleanup: modernize threading idioms (with-block lock handling, time.sleep, super()) without behaviour change
-- Markus Rosenstihl <markus.rosenstihl@pkm.tu-darmstadt.de> Sat, 24 Oct 2026 00:24:10 +0200
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 python3-damaris (0.32) stable; urgency=medium
* fix removal of plot artists: Axes.lines/collections are read-only ArtistLists since matplotlib 3.5 * fix removal of plot artists: Axes.lines/collections are read-only ArtistLists since matplotlib 3.5
+3 -2
View File
@@ -51,12 +51,13 @@ Following is a short description of the options:
10. Filename of the data pool file 10. Filename of the data pool file
11. Write interval after which the data pool is rewritten 11. Write interval after which the data pool is rewritten
12. Compression settings 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 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 Experiment Script
================= =================
+1 -1
View File
@@ -15,7 +15,7 @@
## module contents ## module contents
# #
__version__ = "0.32" __version__ = "0.34"
__all__=["experiments", "data", "gui"] __all__=["experiments", "data", "gui"]
import sys import sys
+10 -9
View File
@@ -13,7 +13,7 @@ class StateSimple(StateBase):
super(StateSimple, self).__init__() super(StateSimple, self).__init__()
if time < 0: if time < 0:
raise AssertionError("time for state is negative!") raise AssertionError("time for state is negative!")
self.time = time self.time = float(time)
self.content = content self.content = content
def to_xml(self, indent: str = "") -> str: def to_xml(self, indent: str = "") -> str:
@@ -191,7 +191,7 @@ class Experiment:
Typically only used in very special cases. 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 self.total_time[-1] += time
## End of *state_start* ## End of *state_start*
@@ -404,10 +404,10 @@ class Experiment:
The state length is 2 µs. Stabilisation time depends on RF source. The state length is 2 µs. Stabilisation time depends on RF source.
:param float frequency: frequency to set in Hz :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) :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: if ttls != 0:
s_content += '<ttlout value="0x%06x"/>' % ttls s_content += '<ttlout value="0x%06x"/>' % ttls
self.state_list.append(StateSimple(2e-6, s_content)) 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 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 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. - 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: if ttls!=0:
s_content += '<ttlout value="%d"/>' % ttls s_content += '<ttlout value="%d"/>' % ttls
self.state_list.append(StateSimple(0.5e-6, s_content)) self.state_list.append(StateSimple(0.5e-6, s_content))
@@ -680,19 +681,19 @@ class Experiment:
value="" value=""
elif type(value) is float or isinstance(value, numpy.floating): elif type(value) is float or isinstance(value, numpy.floating):
type_string="Float" type_string="Float"
value=repr(value) value=repr(float(value))
elif type(value) is int or isinstance(value, numpy.integer): elif type(value) is int or isinstance(value, numpy.integer):
type_string="Int" type_string="Int"
value=repr(value) value=repr(int(value))
elif type(value) is int: elif type(value) is int:
type_string="Long" type_string="Long"
value=repr(value) value=repr(value)
elif type(value) is complex or isinstance(value, numpy.complexfloating): elif type(value) is complex or isinstance(value, numpy.complexfloating):
type_string="Complex" type_string="Complex"
value=repr(value) value=repr(complex(value))
elif type(value) is bool or isinstance(value, numpy.bool_): elif type(value) is bool or isinstance(value, numpy.bool_):
type_string="Boolean" type_string="Boolean"
value=repr(value) value=repr(bool(value))
elif type(value) in (str,): elif type(value) in (str,):
type_string="String" type_string="String"
else: else:
+1 -1
View File
@@ -25,7 +25,7 @@ This class handles the backend driver
class BackendDriver(threading.Thread): class BackendDriver(threading.Thread):
def __init__(self, executable, spool, clear_jobs=False, clear_results=False): def __init__(self, executable, spool, clear_jobs=False, clear_results=False):
threading.Thread.__init__(self, name="Backend Driver") super().__init__(name="Backend Driver")
self.core_pid = None self.core_pid = None
self.core_input = None self.core_input = None
self.core_output = None self.core_output = None
+50 -25
View File
@@ -717,6 +717,8 @@ class DamarisGUI:
# cleanup # cleanup
self.si = None self.si = None
if self.monitor.overflow_job_ids:
logger.warning( "ADC overflow occurred in jobs: %s", ', '.join( [str( j ) for j in self.monitor.overflow_job_ids] ))
self.state = DamarisGUI.Edit_State self.state = DamarisGUI.Edit_State
self.sw.enable_editing( ) self.sw.enable_editing( )
self.toolbar_run_button.set_sensitive( True ) self.toolbar_run_button.set_sensitive( True )
@@ -871,7 +873,10 @@ class DamarisGUI:
temperature_history_start = self.temperature_history_start temperature_history_start = self.temperature_history_start
self.temperature_history_start = None self.temperature_history_start = None
self.data.pop("temperature", 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()) temperature_history_start, time.time())
if temperature is not None: if temperature is not None:
self.data["temperature"] = temperature self.data["temperature"] = temperature
@@ -920,6 +925,8 @@ class DamarisGUI:
self.lockfile.del_experiment(self.id) self.lockfile.del_experiment(self.id)
# now everything is stopped # now everything is stopped
if self.monitor.overflow_job_ids:
logger.warning( "ADC overflow occurred in jobs: %s", ', '.join( [str( j ) for j in self.monitor.overflow_job_ids] ))
self.state = DamarisGUI.Edit_State self.state = DamarisGUI.Edit_State
self.sw.enable_editing( ) self.sw.enable_editing( )
self.toolbar_run_button.set_sensitive( True ) self.toolbar_run_button.set_sensitive( True )
@@ -1259,7 +1266,7 @@ class DamarisGUI:
class start_browser( threading.Thread ): class start_browser( threading.Thread ):
def __init__( self, url ): def __init__( self, url ):
threading.Thread.__init__( self, name="manual browser" ) super().__init__(name="manual browser")
self.my_webbrowser = None self.my_webbrowser = None
self.my_webbrowser_process = None self.my_webbrowser_process = None
self.start_url = url self.start_url = url
@@ -2379,6 +2386,27 @@ class ConfigTab:
backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0) backend_vbox.pack_start(self.config_adc_bit_depth_hbox, False, False, 0)
self.config_adc_bit_depth_hbox.show_all() self.config_adc_bit_depth_hbox.show_all()
# temperature 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 # insert version informations
components_text = """ components_text = """
operating system %(os)s operating system %(os)s
@@ -2485,7 +2513,9 @@ pygobject version %(pygobject)s
"data_pool_complib": complib, "data_pool_complib": complib,
"data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ), "data_pool_comprate": self.config_data_pool_comprate.get_value_as_int( ),
"script_font": self.config_script_font_button.get_font_name( ), "script_font": self.config_script_font_button.get_font_name( ),
"adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int() "adc_bit_depth": self.config_adc_bit_depth_spinbutton.get_value_as_int(),
"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"] ADC_Result.default_bit_depth = actual_config["adc_bit_depth"]
return actual_config return actual_config
@@ -2520,6 +2550,10 @@ pygobject version %(pygobject)s
ADC_Result.default_bit_depth = int(config["adc_bit_depth"]) ADC_Result.default_bit_depth = int(config["adc_bit_depth"])
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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: if "data_pool_complib" in config:
# find combo-box entry and make it active... # find combo-box entry and make it active...
@@ -3246,9 +3280,8 @@ class MonitorWidgets:
self.data_pool.unregister_listener( self.datapool_listener ) self.data_pool.unregister_listener( self.datapool_listener )
self.data_pool = None self.data_pool = None
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter = 0 self.update_counter = 0
self.update_counter_lock.release( )
# display states # display states
self.source_list_reset( ) self.source_list_reset( )
@@ -3300,24 +3333,21 @@ class MonitorWidgets:
displayed_object = self.displayed_data[ 1 ] displayed_object = self.displayed_data[ 1 ]
object_to_display = self.data_pool.get( event.subject ) object_to_display = self.data_pool.get( event.subject )
if displayed_object is None or object_to_display is None: if displayed_object is None or object_to_display is None:
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release( )
glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE )
else: else:
if event.what == DataPool.Event.updated_value and \ if event.what == DataPool.Event.updated_value and \
( (
displayed_object is object_to_display or displayed_object.__class__ is object_to_display.__class__): displayed_object is object_to_display or displayed_object.__class__ is object_to_display.__class__):
# oh, another category # oh, another category
self.update_counter_lock.acquire() with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release()
glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ], glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ],
priority=glib.PRIORITY_DEFAULT_IDLE ) priority=glib.PRIORITY_DEFAULT_IDLE )
else: else:
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release( )
glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE )
if event.what in [ DataPool.Event.updated_value, DataPool.Event.new_key ]: if event.what in [ DataPool.Event.updated_value, DataPool.Event.new_key ]:
@@ -3337,14 +3367,13 @@ class MonitorWidgets:
if self.update_counter > 5: if self.update_counter > 5:
if debug: if debug:
logger.debug("Sleeping to find time for graphics updates") logger.debug("Sleeping to find time for graphics updates")
threading.Event( ).wait( 0.05 ) time.sleep(0.05)
while self.update_counter > 15: while self.update_counter > 15:
threading.Event( ).wait( 0.05 ) time.sleep(0.05)
if event.origin is not self.displayed_data[ 1 ]: if event.origin is not self.displayed_data[ 1 ]:
return return
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release( )
glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ], glib.idle_add(self.update_display_idle_event, self.displayed_data[ 0 ][ : ],
priority=glib.PRIORITY_DEFAULT_IDLE ) priority=glib.PRIORITY_DEFAULT_IDLE )
@@ -3354,9 +3383,8 @@ class MonitorWidgets:
""" """
here dictionary changes are done here dictionary changes are done
""" """
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter -= 1 self.update_counter -= 1
self.update_counter_lock.release( )
if event.what == DataPool.Event.updated_value: if event.what == DataPool.Event.updated_value:
if (self.displayed_data[ 0 ] is not None and if (self.displayed_data[ 0 ] is not None and
@@ -3414,9 +3442,8 @@ class MonitorWidgets:
def update_display_idle_event( self, subject=None ): def update_display_idle_event( self, subject=None ):
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter -= 1 self.update_counter -= 1
self.update_counter_lock.release( )
# print "update display", self.update_counter # print "update display", self.update_counter
if self.update_counter > 10: if self.update_counter > 10:
logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter) logger.warning("Update queue too long (%d>10): skipping one update", self.update_counter)
@@ -3461,9 +3488,8 @@ class MonitorWidgets:
self.display_source_path_label.set_label( "" ) self.display_source_path_label.set_label( "" )
self.clear_display( ) self.clear_display( )
# renew display via idle event # renew display via idle event
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release( )
event = DataPool.Event( DataPool.Event.updated_value, new_data_name ) event = DataPool.Event( DataPool.Event.updated_value, new_data_name )
glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE ) glib.idle_add(self.datapool_idle_listener, event, priority=glib.PRIORITY_DEFAULT_IDLE )
@@ -3480,9 +3506,8 @@ class MonitorWidgets:
and source.get_number_of_channels( ) == 2: and source.get_number_of_channels( ) == 2:
# coalesce like the other display updates, dragging the slider # coalesce like the other display updates, dragging the slider
# emits one event per pointer motion # emits one event per pointer motion
self.update_counter_lock.acquire( ) with self.update_counter_lock:
self.update_counter += 1 self.update_counter += 1
self.update_counter_lock.release( )
glib.idle_add( self.update_display_idle_event, self.displayed_data[ 0 ][ : ], glib.idle_add( self.update_display_idle_event, self.displayed_data[ 0 ][ : ],
priority=glib.PRIORITY_DEFAULT_IDLE ) priority=glib.PRIORITY_DEFAULT_IDLE )
@@ -4244,13 +4269,13 @@ class MonitorWidgets:
# finally decide about x log plot # finally decide about x log plot
if x_scale == "log10" and xmin > 0: 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 self.matplot_axes.fmt_xdata = lambda x: "%g" % x
#elif x_scale=="log" and xmin>0: #elif x_scale=="log" and xmin>0:
# e scaling implementation not really useful # e scaling implementation not really useful
# self.matplot_axes.set_xscale("log", basex=numpy.e) # self.matplot_axes.set_xscale("log", basex=numpy.e)
if y_scale == "log10" and ymin > 0: 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.matplot_axes.fmt_ydata = lambda x: "%g" % x
self.__rescale = False self.__rescale = False
+1 -1
View File
@@ -56,7 +56,7 @@ class ExperimentHandling(threading.Thread):
""" """
def __init__(self, script, exp_writer, data, log_callback=None): def __init__(self, script, exp_writer, data, log_callback=None):
threading.Thread.__init__(self, name="experiment handler") super().__init__(name="experiment handler")
self.script=script self.script=script
self.writer=exp_writer self.writer=exp_writer
self.data=data self.data=data
+1 -1
View File
@@ -17,7 +17,7 @@ class ResultHandling(threading.Thread):
""" """
def __init__(self, script_data, result_iterator, data_pool, log_callback=None): def __init__(self, script_data, result_iterator, data_pool, log_callback=None):
threading.Thread.__init__(self,name="result handler") super().__init__(name="result handler")
self.script=script_data self.script=script_data
self.results=result_iterator self.results=result_iterator
self.data_space=data_pool self.data_space=data_pool
+4 -2
View File
@@ -135,7 +135,8 @@ class ResultReader:
elif t == "Complex": elif t == "Complex":
self.result_description[k]=complex(v) self.result_description[k]=complex(v)
elif t == "Boolean": 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": elif t == "String":
self.result_description[k]=v self.result_description[k]=v
else: else:
@@ -502,7 +503,8 @@ class ResultReader:
elif t == "Complex": elif t == "Complex":
self.result_description[k]=complex(v) self.result_description[k]=complex(v)
elif t == "Boolean": 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": elif t == "String":
self.result_description[k]=v self.result_description[k]=v
else: 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: Example:
client = TemperatureProxyClient() client = TemperatureProxyClient() # defaults to http://127.0.0.1:18007
temperature = client.get_history(start_epoch, end_epoch) temperature = client.get_history(start_epoch, end_epoch)
""" """
@@ -18,11 +18,18 @@ logger = logging.getLogger(__name__)
class TemperatureProxyClient: 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 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: def get_history(self, start_epoch: float, end_epoch: float) -> TemperatureResult | None:
"""Return temperature history relative to the experiment start, or ``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) temperature = TemperatureProxyClient().get_history(1000.0, 1060.0)
""" """
query = urlencode({"start": start_epoch, "end": end_epoch}) query = urlencode({"start": start_epoch, "end": end_epoch})
url = f"{self.URL}?{query}" url = f"{self.url}?{query}"
try: try:
with urlopen(url, timeout=self.TIMEOUT) as response: 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")) return io.BytesIO(json.dumps(payload).encode("utf-8"))
@patch("damaris.gui.TemperatureProxyClient.urlopen") @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({ mock_urlopen.return_value = self.response({
"readings": [ "readings": [
{"t": 101.5, "pv": 293.15}, {"t": 101.5, "pv": 293.15},
@@ -35,13 +35,25 @@ class TestTemperatureProxyClient(unittest.TestCase):
args, kwargs = mock_urlopen.call_args args, kwargs = mock_urlopen.call_args
parsed_url = urlparse(args[0]) parsed_url = urlparse(args[0])
self.assertEqual(parsed_url.scheme, "http") 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(parsed_url.path, "/v1/readings")
self.assertEqual(parse_qs(parsed_url.query), {"start": ["100.0"], "end": ["110.0"]}) self.assertEqual(parse_qs(parsed_url.query), {"start": ["100.0"], "end": ["110.0"]})
self.assertEqual(kwargs, {"timeout": TemperatureProxyClient.TIMEOUT}) self.assertEqual(kwargs, {"timeout": TemperatureProxyClient.TIMEOUT})
self.assertEqual(result.x, [1.5, 4.0]) self.assertEqual(result.x, [1.5, 4.0])
self.assertEqual(result.y, [293.15, 294.25]) 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") @patch("damaris.gui.TemperatureProxyClient.urlopen")
def test_empty_readings_return_none(self, mock_urlopen): def test_empty_readings_return_none(self, mock_urlopen):
mock_urlopen.return_value = self.response({"readings": []}) mock_urlopen.return_value = self.response({"readings": []})
@@ -62,7 +74,7 @@ class TestTemperatureProxyClient(unittest.TestCase):
OSError("connection refused"), OSError("connection refused"),
TimeoutError("request timed out"), TimeoutError("request timed out"),
http.client.BadStatusLine("not an HTTP response"), 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: for failure in failures:
with self.subTest(failure=failure): with self.subTest(failure=failure):