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.088labbench: context order: (spectrum_analyzer, power_sensor)->(<__main__.Measurement object at 0x11bb90190>)
 DEBUG  2024-03-20 13:42:57,638.639power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
 DEBUG  2024-03-20 13:42:57,641.641power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,642.642power_sensor: opened
 DEBUG  2024-03-20 13:42:57,868.868spectrum_analyzer: probed resource by matching make 'FakeTech', model 'Spectrum Analyzer #1234'
 DEBUG  2024-03-20 13:42:57,870.871spectrum_analyzer: 'USB0::0x1111::0x2222::0x4445::0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,871.872spectrum_analyzer: opened
 DEBUG  2024-03-20 13:42:57,872.872labbench: entry into context for  1.782 s elapsed
 DEBUG  2024-03-20 13:42:57,873.873labbench: opened
 DEBUG  2024-03-20 13:42:57,873.874labbench: entry into context for <__main__.Measurement object at 0x11bb90190> 1.783 s elapsed
 DEBUG  2024-03-20 13:42:57,874.874spectrum_analyzer: query('SENS:FREQ?'):
 DEBUG  2024-03-20 13:42:57,874.875spectrum_analyzer:     → '10000000.000000'
 DEBUG  2024-03-20 13:42:57,875.875spectrum_analyzer: center_frequency → 10000000.0  (Hz)
 DEBUG  2024-03-20 13:42:57,875.876labbench: closed
 DEBUG  2024-03-20 13:42:57,877.877spectrum_analyzer: closed
 DEBUG  2024-03-20 13:42:57,877.878power_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.891labbench: context order: (generator, spectrum_analyzer, power_sensor)->(measurement,<__main__.Testbed object at 0x11bbb8370>)
 DEBUG  2024-03-20 13:42:57,917.917spectrum_analyzer: opened
 DEBUG  2024-03-20 13:42:57,918.919generator: probed resource by matching make 'FakeTech', model 'Signal Generator #1234'
 DEBUG  2024-03-20 13:42:57,919.919generator: 'TCPIP0::localhost:10001::inst0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,919.920power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
 DEBUG  2024-03-20 13:42:57,920.920generator: opened
 DEBUG  2024-03-20 13:42:57,920.921power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,921.921power_sensor: opened
 DEBUG  2024-03-20 13:42:57,921.922measurement: opened
 DEBUG  2024-03-20 13:42:57,921.922labbench: opened
 DEBUG  2024-03-20 13:42:57,922.922spectrum_analyzer: query('SENS:FREQ?'):
 DEBUG  2024-03-20 13:42:57,922.923spectrum_analyzer:     → '10000000.000000'
 DEBUG  2024-03-20 13:42:57,922.923spectrum_analyzer: center_frequency → 10000000.0  (Hz)
 DEBUG  2024-03-20 13:42:57,923.923generator: query('SENS:FREQ?'):
 DEBUG  2024-03-20 13:42:57,923.923generator:     → '10000000.000000'
 DEBUG  2024-03-20 13:42:57,923.924generator: center_frequency → 10000000.0  (Hz)
 DEBUG  2024-03-20 13:42:57,923.924labbench: closed
 DEBUG  2024-03-20 13:42:57,924.924measurement: closed
 DEBUG  2024-03-20 13:42:57,924.924power_sensor: closed
 DEBUG  2024-03-20 13:42:57,924.925generator: closed
 DEBUG  2024-03-20 13:42:57,924.925spectrum_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.934labbench: context order: (generator, spectrum_analyzer, power_sensor)->(measurement,<__main__.Testbed object at 0x11bb7d7c0>)
 DEBUG  2024-03-20 13:42:57,936.936generator: probed resource by matching make 'FakeTech', model 'Signal Generator #1234'
 DEBUG  2024-03-20 13:42:57,936.936spectrum_analyzer: probed resource by matching make 'FakeTech', model 'Spectrum Analyzer #1234'
 DEBUG  2024-03-20 13:42:57,936.937generator: 'TCPIP0::localhost:10001::inst0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,936.937power_sensor: probed resource by matching make 'FakeTech', model 'Power Sensor #1234'
 DEBUG  2024-03-20 13:42:57,937.937spectrum_analyzer: 'USB0::0x1111::0x2222::0x4445::0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,937.937generator: opened
 DEBUG  2024-03-20 13:42:57,937.938power_sensor: 'USB0::0x1111::0x2222::0x1234::0::INSTR'  → resource
 DEBUG  2024-03-20 13:42:57,938.938spectrum_analyzer: opened
 DEBUG  2024-03-20 13:42:57,938.939power_sensor: opened
 INFO   2024-03-20 13:42:57,939.939labbench: Measurement open()
 DEBUG  2024-03-20 13:42:57,939.940power_sensor: write('SYST:PRES')
 DEBUG  2024-03-20 13:42:57,939.940measurement: opened
 INFO   2024-03-20 13:42:57,939.940labbench: Sweep open()
 DEBUG  2024-03-20 13:42:57,940.940generator: write('*RST')
 DEBUG  2024-03-20 13:42:57,940.940labbench: opened
 INFO   2024-03-20 13:42:57,940.941labbench: Sweep close()
 DEBUG  2024-03-20 13:42:57,940.941labbench: closed
 INFO   2024-03-20 13:42:57,941.941labbench: Measurement close()
 DEBUG  2024-03-20 13:42:57,941.941measurement: closed
 DEBUG  2024-03-20 13:42:57,941.941power_sensor: closed
 DEBUG  2024-03-20 13:42:57,941.942spectrum_analyzer: closed
 DEBUG  2024-03-20 13:42:57,941.942generator: 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.955labbench: starting frequency sweep across 3 points
 INFO   2024-03-20 13:42:57,955.955labbench: 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'