Testbed Organization¶
The primary tool in labbench for testbed organization is the labbench.Rack
class. These act as nestable containers for groups of different labbench.Device
objects with associated automation routines.
Racks as containers¶
The basic use of labbench.Rack
is to create a container that groups together different labbench.Device
objects. In order to reduce python object boilerplate, they are written in the style of dataclasses. As an example, to group together two types of detecting instruments:
import labbench as lb
# simulated instruments
from labbench.testing.pyvisa_sim import PowerSensor, SpectrumAnalyzer
class Measurement(lb.Rack):
# the annotation (":" notation) specifies that power_sensor
# can be set later when we create a Measurement object
spectrum_analyzer: SpectrumAnalyzer = SpectrumAnalyzer()
# if we don't set a default value in the class (the "=" notation),
# then it *must* be set as a keyword argument to create Measurement
power_sensor: PowerSensor
# the resulting call signature for creating a Measurement
%pdef Measurement
Class constructor information:
Measurement(
*,
spectrum_analyzer: labbench.testing.pyvisa_sim.SpectrumAnalyzer = SpectrumAnalyzer(),
power_sensor: labbench.testing.pyvisa_sim.PowerSensor,
)
This annotation notation gives users the ability to configure the device attributes, such as its resource or address string, at runtime outside of the class definition.
To connect the device in this container together, the first step is to instantiate an object from the Measuerement
class. Like all labbench.Device
objects, Rack objects all have open
and close
methods, which are called automatically by use of the with
context manager block.
lb.visa_default_resource_manager('@sim-labbench') # the simulated backend for these instruments
lb.show_messages('debug')
meas = Measurement(power_sensor=PowerSensor())
with meas:
print('Spectrum analyzer center frequency: ', meas.spectrum_analyzer.center_frequency)
Spectrum analyzer center frequency: 10000000.0
DEBUG 2024-03-20 13:42:56,088.088 • labbench: context order: (spectrum_analyzer, power_sensor)->(<__main__.Measurement object at 0x11bb90190>)
DEBUG 2024-03-20 13:42:57,638.639 • power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
DEBUG 2024-03-20 13:42:57,641.641 • power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,642.642 • power_sensor: opened
DEBUG 2024-03-20 13:42:57,868.868 • spectrum_analyzer: probed resource by matching make 'FakeTech', model 'Spectrum Analyzer #1234'
DEBUG 2024-03-20 13:42:57,870.871 • spectrum_analyzer: 'USB0::0x1111::0x2222::0x4445::0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,871.872 • spectrum_analyzer: opened
DEBUG 2024-03-20 13:42:57,872.872 • labbench: entry into context for 1.782 s elapsed
DEBUG 2024-03-20 13:42:57,873.873 • labbench: opened
DEBUG 2024-03-20 13:42:57,873.874 • labbench: entry into context for <__main__.Measurement object at 0x11bb90190> 1.783 s elapsed
DEBUG 2024-03-20 13:42:57,874.874 • spectrum_analyzer: query('SENS:FREQ?'):
DEBUG 2024-03-20 13:42:57,874.875 • spectrum_analyzer: → '10000000.000000'
DEBUG 2024-03-20 13:42:57,875.875 • spectrum_analyzer: center_frequency → 10000000.0 (Hz)
DEBUG 2024-03-20 13:42:57,875.876 • labbench: closed
DEBUG 2024-03-20 13:42:57,877.877 • spectrum_analyzer: closed
DEBUG 2024-03-20 13:42:57,877.878 • power_sensor: closed
The debug messages show how our Measurement
container opened all of the connections before the automation functions were performed.
Nested racks¶
Rack objects can be nested together, resulting in recursive context management of all devices by a top-level class. For example:
from labbench.testing.pyvisa_sim import SignalGenerator, PowerSensor, SpectrumAnalyzer
class Testbed(lb.Rack):
# as with Device objects, we annotate a type to allow
measurement: Measurement = Measurement(power_sensor=PowerSensor())
# Device and Rack instances can be mixed and matched
generator: SignalGenerator = SignalGenerator()
with Testbed() as sweep:
print('Spectrum analyzer center frequency: ', sweep.measurement.spectrum_analyzer.center_frequency)
print('Signal generator center frequency: ', sweep.generator.center_frequency)
Spectrum analyzer center frequency: 10000000.0
Signal generator center frequency: 10000000.0
DEBUG 2024-03-20 13:42:57,890.891 • labbench: context order: (generator, spectrum_analyzer, power_sensor)->(measurement,<__main__.Testbed object at 0x11bbb8370>)
DEBUG 2024-03-20 13:42:57,917.917 • spectrum_analyzer: opened
DEBUG 2024-03-20 13:42:57,918.919 • generator: probed resource by matching make 'FakeTech', model 'Signal Generator #1234'
DEBUG 2024-03-20 13:42:57,919.919 • generator: 'TCPIP0::localhost:10001::inst0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,919.920 • power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
DEBUG 2024-03-20 13:42:57,920.920 • generator: opened
DEBUG 2024-03-20 13:42:57,920.921 • power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,921.921 • power_sensor: opened
DEBUG 2024-03-20 13:42:57,921.922 • measurement: opened
DEBUG 2024-03-20 13:42:57,921.922 • labbench: opened
DEBUG 2024-03-20 13:42:57,922.922 • spectrum_analyzer: query('SENS:FREQ?'):
DEBUG 2024-03-20 13:42:57,922.923 • spectrum_analyzer: → '10000000.000000'
DEBUG 2024-03-20 13:42:57,922.923 • spectrum_analyzer: center_frequency → 10000000.0 (Hz)
DEBUG 2024-03-20 13:42:57,923.923 • generator: query('SENS:FREQ?'):
DEBUG 2024-03-20 13:42:57,923.923 • generator: → '10000000.000000'
DEBUG 2024-03-20 13:42:57,923.924 • generator: center_frequency → 10000000.0 (Hz)
DEBUG 2024-03-20 13:42:57,923.924 • labbench: closed
DEBUG 2024-03-20 13:42:57,924.924 • measurement: closed
DEBUG 2024-03-20 13:42:57,924.924 • power_sensor: closed
DEBUG 2024-03-20 13:42:57,924.925 • generator: closed
DEBUG 2024-03-20 13:42:57,924.925 • spectrum_analyzer: closed
This time, Sweep
opened connections to all three instruments, even though two were nested inside measurement
. In fact, these connections are managed properly even if a device is shared by more than one nested rack.
Custom setup and teardown in Rack¶
Rack classes can define functions that execute snippets of measurement procedures within the scope of its owned devices. These include an open
method to initialize the state of the group of instruments. For example, extending our container objects:
from labbench.util import logger
class Measurement(lb.Rack):
spectrum_analyzer: SpectrumAnalyzer = SpectrumAnalyzer()
power_sensor: PowerSensor
def open(self):
# this is called automatically after its owned devices are opened
logger.info('Measurement open()')
self.power_sensor.preset()
def close(self):
logger.info('Measurement close()')
class Testbed(lb.Rack):
generator: SignalGenerator = SignalGenerator()
measurement: Measurement = Measurement(power_sensor=PowerSensor())
def open(self):
# the last open() call is here after everything else has opened
logger.info('Sweep open()')
self.generator.preset()
def close(self):
# the first close() call is here before nested objects
logger.info('Sweep close()')
with Testbed() as sweep:
pass
DEBUG 2024-03-20 13:42:57,934.934 • labbench: context order: (generator, spectrum_analyzer, power_sensor)->(measurement,<__main__.Testbed object at 0x11bb7d7c0>)
DEBUG 2024-03-20 13:42:57,936.936 • generator: probed resource by matching make 'FakeTech', model 'Signal Generator #1234'
DEBUG 2024-03-20 13:42:57,936.936 • spectrum_analyzer: probed resource by matching make 'FakeTech', model 'Spectrum Analyzer #1234'
DEBUG 2024-03-20 13:42:57,936.937 • generator: 'TCPIP0::localhost:10001::inst0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,936.937 • power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
DEBUG 2024-03-20 13:42:57,937.937 • spectrum_analyzer: 'USB0::0x1111::0x2222::0x4445::0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,937.937 • generator: opened
DEBUG 2024-03-20 13:42:57,937.938 • power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR' → resource
DEBUG 2024-03-20 13:42:57,938.938 • spectrum_analyzer: opened
DEBUG 2024-03-20 13:42:57,938.939 • power_sensor: opened
INFO 2024-03-20 13:42:57,939.939 • labbench: Measurement open()
DEBUG 2024-03-20 13:42:57,939.940 • power_sensor: write('SYST:PRES')
DEBUG 2024-03-20 13:42:57,939.940 • measurement: opened
INFO 2024-03-20 13:42:57,939.940 • labbench: Sweep open()
DEBUG 2024-03-20 13:42:57,940.940 • generator: write('*RST')
DEBUG 2024-03-20 13:42:57,940.940 • labbench: opened
INFO 2024-03-20 13:42:57,940.941 • labbench: Sweep close()
DEBUG 2024-03-20 13:42:57,940.941 • labbench: closed
INFO 2024-03-20 13:42:57,941.941 • labbench: Measurement close()
DEBUG 2024-03-20 13:42:57,941.941 • measurement: closed
DEBUG 2024-03-20 13:42:57,941.941 • power_sensor: closed
DEBUG 2024-03-20 13:42:57,941.942 • spectrum_analyzer: closed
DEBUG 2024-03-20 13:42:57,941.942 • generator: closed
The call order of open()
methods is always in this order: first, all nested labbench.Device
objects, recursively, and then all rack objects, beginning from the deepest nesting level and proceeding to the top.
Note:
All labbench.Rack
and labbench.Device
objects have special-case inheritance behavior for open()
and close()
methods. These enforce calls to all nested and inherited types
in order to enforce the sequencing required to for cross-dependency in racks.
As a result, calling super().open()
or super().close()
is redundant and unnecessary.
Procedural snippets¶
As an organizational tool, short pieces of experimental procedure can be expressed by implementing methods (class-level functions) in each rack:
class Measurement(lb.Rack):
spectrum_analyzer: SpectrumAnalyzer = SpectrumAnalyzer()
power_sensor: PowerSensor
def setup(self, *, center_frequency):
self.spectrum_analyzer.load_state("state_filename")
self.spectrum_analyzer.center_frequency = center_frequency
self.spectrum_analyzer.resolution_bandwidth = 10e6
self.power_sensor.preset()
self.power_sensor.frequency = center_frequency
def acquire(self):
self.spectrum_analyzer.trigger()
def fetch(self):
spectrum = self.spectrum_analyzer.fetch()
pvt = self.power_sensor.fetch()
return {
'spectrum': spectrum,
'power': pvt
}
class Testbed(lb.Rack):
generator: SignalGenerator = SignalGenerator()
measurement: Measurement = Measurement(power_sensor=PowerSensor())
def setup(self, center_frequency: float):
self.generator.center_frequency = center_frequency
self.measurement.setup(center_frequency=center_frequency)
def single_frequency(self, *, center_frequency):
logger.info(f'single frequency test at {center_frequency/1e6:0.3f} MHz')
self.generator.output_enabled = True
self.measurement.acquire()
self.generator.output_enabled = False
return self.measurement.fetch()
def sweep(self, frequencies):
logger.info(f'starting frequency sweep across {len(frequencies)} points')
ret = []
for freq in frequencies:
ret.append(self.single_frequency(center_frequency=freq))
return ret
lb.show_messages('info')
with Testbed() as testbed:
data = testbed.sweep(frequencies=[2.4e9, 2.44e9, 2.48e9])
INFO 2024-03-20 13:42:57,955.955 • labbench: starting frequency sweep across 3 points
INFO 2024-03-20 13:42:57,955.955 • labbench: single frequency test at 2400.000 MHz
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[5], line 52
49 lb.show_messages('info')
51 with Testbed() as testbed:
---> 52 data = testbed.sweep(frequencies=[2.4e9, 2.44e9, 2.48e9])
Cell In[5], line 52
49 lb.show_messages('info')
51 with Testbed() as testbed:
---> 52 data = testbed.sweep(frequencies=[2.4e9, 2.44e9, 2.48e9])
Cell In[5], line 45, in Testbed.sweep(self, frequencies)
42 ret = []
44 for freq in frequencies:
---> 45 ret.append(self.single_frequency(center_frequency=freq))
47 return ret
Cell In[5], line 38, in Testbed.single_frequency(self, center_frequency)
36 self.measurement.acquire()
37 self.generator.output_enabled = False
---> 38 return self.measurement.fetch()
Cell In[5], line 18, in Measurement.fetch(self)
16 def fetch(self):
17 spectrum = self.spectrum_analyzer.fetch()
---> 18 pvt = self.power_sensor.fetch()
19 return {
20 'spectrum': spectrum,
21 'power': pvt
22 }
File ~/Documents/src/labbench/src/labbench/testing/pyvisa_sim.py:65, in PowerSensor.fetch(self)
62 response = self.query('FETC?')
64 if self.trigger_count == 1:
---> 65 return float(response)
66 else:
67 return pd.Series([float(s) for s in response.split(',')], name='spectrum')
ValueError: could not convert string to float: '-52.617,-52.373,-52.724,-51.893,-52.27,-52.047,-53.059,-52.053,-52.426,-52.343,-52.228,-52.976,-52.186,-53.0,-51.894,-53.18,-51.96,-52.326,-52.492,-52.871,-52.41,-53.111,-53.199,-52.907,-52.791,-52.68,-51.63,-51.679,-51.743,-52.613,-52.108,-53.138,-52.014,-52.289,-52.235,-52.26,-53.135,-52.503,-52.201,-51.633,-51.933,-52.82,-52.287,-52.594,-51.89,-52.371,-52.068,-51.888,-53.145,-53.085,-52.392,-52.064,-51.688,-52.188,-52.211,-52.226,-52.841,-51.951,-51.573,-51.521,-52.115,-52.302,-52.958,-52.503,-52.32,-52.81,-52.357,-51.729,-52.956,-52.849,-51.883,-51.505,-52.027,-52.234,-52.092,-51.446,-52.798,-51.601,-52.14,-51.477,-52.614,-52.291,-52.532,-52.861,-51.814,-51.821,-52.997,-53.184,-51.761,-53.052,-51.612,-52.876,-52.013,-52.252,-52.059,-52.806,-52.474,-51.689,-52.606,-51.924,-51.964,-51.601,-52.815,-53.172,-52.183,-53.071,-52.763,-52.999,-52.595,-52.463,-52.48,-52.701,-52.337,-51.778,-52.039,-51.493,-51.591,-51.654,-51.525,-52.925,-51.531,-53.169,-52.997,-52.519,-52.298,-52.078,-52.547,-51.518,-51.589,-51.567,-51.502,-51.984,-52.215,-52.681,-51.468,-53.197,-53.007,-51.929,-52.465,-53.132,-52.073,-51.75,-52.8,-52.054,-52.493,-51.605,-53.026,-52.28,-52.331,-52.109,-51.889,-52.878,-51.874,-51.801,-52.031,-52.625,-51.84,-53.029,-52.431,-51.655,-52.51,-52.431,-52.165,-52.009,-51.973,-53.042,-52.632,-51.754,-52.637,-51.757,-51.9,-52.775,-52.49,-52.022,-52.151,-52.05,-51.867,-52.494,-53.014,-52.14,-53.036,-51.799,-51.848,-51.996,-52.254,-52.75,-51.492,-51.755,-52.494,-53.193,-53.114,-53.028,-52.898,-52.992,-53.127,-51.752,-53.065,-52.585,-51.861,-51.596'