How to parameterize class creation based on an external attribute

31 Views Asked by At

I am writing a device driver in python for a pulse generator. This device is available with either 4 or 8 channels. Without any prior device knowledge, the channel count can be obtained through a command/response transaction.

Each transaction has been implemented as a descriptor. There are two types of transactions: one that interacts with the device without regard to channel and one that only applies to each channel. My first attempts included making a class for channel-based transactions and making instances for each channel. This didn't work since the channel class is already defined and therefore the descriptors it contains are already coded the messages needed. I also looked at factory methods and simply making a dictionary or list with the various channel instances. In every case, I've failed with not being able to define the channel instances with their specific channel numbers before the class or instances are defined. In the end, I've just copied and pasted the channel-based descriptors but this seems to stink pretty bad.

Can anyone suggest a approach to refactor this in a better form? For instance, I would really like to be able to write

for ch in len(channels):
    b.channels[ch].enable = 1

rather than

b.channel1_enable = 1
b.channel2_enable = 1
b.channel3_enable = 1
...

My current working code looks like this. Note the copy and paste chunks for each channel near the end. I'm hoping there's a better means of encapsulating the channel-based code.

class Transaction():
    def __init__(self, command, name=None):
        self.command = command
        self.name = name
    def __set_name__(self, obj, name):
        if not self.name:
            self.name = name
    def __get__(self, obj, cls):
        raise NotImplementedError
    def __set__(self, obj, value):
        raise NotImplementedError
    def __delete__(self, obj):
        raise NotImplementedError

class Read(Transaction):
    def __get__(self, obj, cls):
        return obj.transact(f'{self.command}?')

class ReadWrite(Read):
    def __set__(self, obj, value):
        return obj.transact(f'{self.command} {value}')
...

number_of_channels = 8
class Pulser:
    def __init__(self, interface='COM1', **kwargs):
        self.interface = serial.Serial(interface, baudrate=9600)
        self.eol = bytes('\r\n', 'utf-8')

    def __set_name__(self, obj, name):
        self.name = name

    def transact(self, command):
        self.interface.write(bytes(command, 'utf-8'))
        self.interface.write(self.eol)
        response = self.interface.read_until(self.eol).decode('utf-8').strip()
        return response

    @property
    def identity(self):
        _ = self.idn
        idn = _.split(',')
        self.manufacturer = idn[0]
        self.model = idn[1]
        self.number_channels = int(self.model[-1])
        self.serial_number = idn[3]
        self.firmware_version = idn[3]
        # b.channels[1].channel_output_enable = True
        return _

    idn = Read('*IDN')
    settings_label = ReadWrite('*LBL')
    list_channel_names = Read(':INST:CAT')
    list_channel_numbers = Read(':INST:FULL')
    list_commands = Read(':INST:COMM')
    display_update = Read(':DISP:UPD')
    system_state = Read(':SYST:STAT')
    ...
    channel1_enable = ReadWrite(':PULS1:STAT')
    channel1_pulse_width_seconds = ReadWrite(':PULS1:WIDT')
    channel1_delay_seconds = ReadWrite(':PULS1:DEL')
    channel1_sync_source = ReadWrite(':PULS1:SYNC')

    channel2_enable = ReadWrite(':PULS2:STAT')
    channel2_pulse_width_seconds = ReadWrite(':PULS2:WIDT')
    channel2_delay_seconds = ReadWrite(':PULS2:DEL')
    channel2_sync_source = ReadWrite(':PULS2:SYNC')

    channel3_enable = ReadWrite(':PULS3:STAT')
    channel3_pulse_width_seconds = ReadWrite(':PULS3:WIDT')
    channel3_delay_seconds = ReadWrite(':PULS3:DEL')
    channel3_sync_source = ReadWrite(':PULS3:SYNC')
    ...
    if number_of_channels > 4:
        # more repeats here for channels 5-8
1

There are 1 best solutions below

0
On

There's no good way to condition the existence of a descriptor on a value of an isntance of your class. That's because the descriptors are set up at class creation time, long before the instance details can be known.

A better approach would be to unconditionally create the decorators, but add logic to (some of) them that checks if they are enabled or not. A callback function is an easy way to do that:

class Switched(ReadWrite):
    def __init__(self, *args, callback, **kwargs): # callback is keyword-only
        self.callback = callback
        super().__init__(*args, **kwargs)

    def __get__(self, obj, cls):
        if not callback(obj):
            raise AttributeError(f"{self.name} is not available on this device")
        return super().__get__(obj, cls)

    def __set__(self, obj, value):
        if not callback(obj):
            raise AttributeError(f"{self.name} is not available on this device")
        return super().__set__(obj, value)

You'd set it up with something like this:

...

channel4_enable = ReadWrite(':PULS4:STAT')
channel4_pulse_width_seconds = ReadWrite(':PULS4:WIDT')
channel4_delay_seconds = ReadWrite(':PULS4:DEL')
channel4_sync_source = ReadWrite(':PULS4:SYNC')

def has_extra_channels(self):
    return self.number_of_channels > 4

channel5_enable = Switched(':PULS5:STAT', callback=has_extra_channels)
channel5_pulse_width_seconds = Switched(':PULS5:WIDT', callback=has_extra_channels)
channel5_delay_seconds = Switched(':PULS5:DEL', callback=has_extra_channels)
channel5_sync_source = Switched(':PULS5:SYNC', callback=has_extra_channels)

...  # etc, for 6-8

As for the more general part of question about avoiding repeating yourself to set this code up, there's not really a good way to do that with descriptors. They need to be defined in the class body, and it's very awkward to try to write code that manipulates them dynamically. But you could consider abandoning the descriptor approach (at least, at the level you're currently using it) and create a Channel class that encapsulates the relevant things you need. Then you can create a list of Channels in the __init__ method of your Pulser class, and at that point you'd be able to query the device to see how many channels were needed.