SDSS-V Drift’s documentation#

This is the documentation for the SDSS-V product drift. The current version is 1.1.1a0. See what’s new.

Overview#

This library provides an asynchronous interface with modbus devices over a TCP ethernet controller (such as this one) and control of the connected I/O modules. The code is a relatively thin wrapper around Pymodbus with the main feature being that it’s possible to define a PLC controller and a complete set of modules as a YAML configuration file which can then be loaded. It also provides convenience methods to read and write to the I/O modules and to convert the read values to physical units.

This code is mostly intended to interface with the SDSS-V FPS electronic boxes but is probably general enough for other uses. It’s originally based on Rick Pogge’s WAGO code.

Installation#

To install, run

pip install sdss-drift

To install from source, git clone or download the code, navigate to the root of the downloaded directory, and do

pip install .

sdss-drift uses Poetry for development. To install it in development mode do

poetry install -E docs

Basic use#

Let’s start with a basic example

>>> from drift import Drift

>>> drift = Drift('10.1.10.1')
>>> module1 = drift.add_module('module1', 40010, channels=4, mode='input')
>>> sensor1 = module1.add_device('sensor1', 0, adaptor='ee_temp', description='Temperature sensor')

>>> await sensor1.read()
(25.0, 'degC')

Here Drift handles the connection to the modbus ethernet interface. Modules represent physical analog or digital I/O components connected to the modbus network. We added a single analog input module with starting modbus address 40010 (note that in modbus addresses start with 40001) and four channels. Finally, we add a single Device to the module, a temperature sensor connected to the first channel (zero-indexed).

drift is an asynchronous library that uses the Python standard library asyncio. Procedures that read or write from the modbus network are defined as coroutines so that the process can be handled asynchronously. This is shown in the fact that we need to await the call to sensor1.read().

It’s also possible to define a module by providing a product serial number

>>> drift.add_module('module2', 40101, model='750-530')
>>> drift['module2'].mode
'output'
>>> drift['module2'].channels
8

By providing the serial number of the WAGO 750-530 digital output we don’t need to provide the mode or number of channels. We still need to provide the initial address for which the module is configured.

Let’s now add a relay to channel 4

>>> drift['module2'].add_device('relay1', 3 coils=True)
>>> relay = drift.get_device('relay1')
>>> await relay.read()
(True, None)
>>> await relay.write(False)
>>> await relay.read()
(False, None)

Note that we create this device with coils=True since we’ll be reading from and writing to a binary coil instead of a register. We can also use get_device to retrieve a device. Module and device names are considered case-insensitive. If the name of the device is not unique, the module-qualified name (e.g., 'module1.relay1') must be used.

Adaptors#

In our first example we created a new device with adaptor='ee_temp'. Adaptors are simply functions that receive a raw value from a register or coil and return a tuple with the physical value and, optionally, the associated units. When read is awaited, the adaptor (if it has been defined) will be called with the raw value and the result returned

>>> await sensor1.read()
(25.0, 'degC')

This can be disabled by using adapt=False

>>> await sensor1.read(adapt=False)
18021

Adaptors can be defined as a function or lambda function

>>> module.add_device('device', 0, adaptor=lambda raw_value: (2 * raw_value, 'K'))

A number of adaptors are provided with drift, see Available adaptors. The name of one of these adaptor functions, as a string, can be used. It’s possible to define an adaptor in the form module1.module2:my_adaptor. In this case from module1.module2 import my_adaptor will be executed and my_adaptor used.

Finally, one can define an adaptor using a mapping of raw value to converted value, as a dictionary or tuple, for example [(1, 2), (4, 8)] will convert raw_value 1 into 2, and 4 into 8. If a raw value not contained in the mapping is read, an error will be raised.

Relays#

Device can be subclassed to provide a better API for specific kinds of device. A typical case of device is a relay, which can be normally open (NO) or normally closed (NC). drift provides a Relay class that simplifies the handling of a relay

>>> from drift import Relay
>>> module.add_device(
    'relay_no', 3, device_class=Relay, relay_type='NO',
    description='A normally open relay'
)

Now we can read the state of the relay

>>> relay = drift.get_device('relay_no')
>>> await relay.read()
('open', None)

Note that this would be equivalent to creating a normal Device with an adaptor such as [(False, 'open'), (True, 'closed')].

Relay comes with a number of convenience functions to open, close, switch, or cycle a relay

>>> await relay.read()
('open', None)
>>> await relay.close()
>>> await relay.read()
('closed', None)
>>> await relay.switch()
>>> await relay.read()
('open', None)

Configuration file#

Programmatically defining the modules and devices in an electronic design can become tiresome once we have more than a bunch of elements. In those cases, we can define the components in a YAML configuration file, for example

# file: sextant.yaml

address: 10.1.10.1
port: 502
modules:
    module_rtd:
        model: "750-450"
        mode: input
        channels: 4
        address: 40009
        description: "Pt RTD sensors"
        devices:
            "RTD1":
                channel: 0
                category: temperature
                adaptor: rtd10
                units: degC
            "RTD2":
                channel: 1
                category: temperature
                adaptor: rtd10
                units: degC
    module_do:
        model: "750-530"
        mode: output
        channels: 8
        address: 40513
        description: "Power relays"
        devices:
            "24V":
                channel: 0
                type: relay
                relay_type: NC
            "5V":
                channel: 1
                type: relay
                relay_type: NO

Most parameters match the arguments of Drift, Module, and Device. Note that for the two relays we indicate that type: relay which will result in using the Relay class instead of the generic Device.

To create a new Drift instance based on this configuration we do

>>> drift = Drift.from_config('sextant.yaml')
>>> len(drift.modules)
2
>>> len(drift['module_do'].devices)
2
>>> await drift.get_device('24v').read()
('closed', None)

API#

class drift.drift.Drift(address, port=502, lock=True, timeout=1)[source]#

Bases: object

Interface to the modbus network.

Drift manages the TCP connection to the modbus ethernet module using Pymodbus. The AsyncModbusTcpClient object can be accessed as Drift.client.

In general the connection is opened and closed using the a context manager

drift = Drift('localhost')
async with drift:
    coil = drift.client.protocol.read_coils(1, count=1)

This is not needed when using Device.read or Device.write, which handle the connection to the server.

Parameters:
  • address (str) – The IP of the TCP modbus server.

  • port (int) – The port of the TCP modbus server.

  • lock (bool) – Whether to lock the client while it’s in use, preventing multiple connections. Only used with the contextual manager.

  • timeout (float) – Connection timeout in seconds.

add_module(name, **kwargs)[source]#

Adds a new module.

Parameters:
  • name (str) – The name of the module.

  • kwargs – Arguments to be passed to Module for initialisation.

Return type:

Module

classmethod from_config(config, **kwargs)[source]#

Loads a Drift from a dictionary or YAML file.

Parameters:

config (str | dict) – A properly formatted configuration dictionary or the path to a YAML file from which it can be read. Refer to Configuration file for details on the format.

get_device(device)[source]#

Gets the Device instance that matches device.

If the case-insensitive name of the device is unique within the pool of connected devices, the device name is sufficient. Otherwise the device must be provided as module.device.

Parameters:

device (str) –

Return type:

Device

async read(*args, **kwargs)[source]#

Alias for read_device.

async read_category(category, adapt=True, connect=True)[source]#

Reads all the devices of a given category.

Parameters:
  • category (str) – The category to match.

  • adapt (bool) – If possible, convert the value to real units.

  • connect (bool) – Whether to connect to the client and disconnect after each read. If connect=False, the user is responsible for connecting and disconnecting.

Returns:

read_values – A dictionary of module-qualified device names and read values, along with their units.

Return type:

dict[str, Any]

async read_device(name, adapt=True)[source]#

Reads a device.

Parameters:
  • name (str) – The device to read.

  • adapt (bool) – If possible, convert the value to real units.

Returns:

read_value – The read value and its associated units.

async read_devices(devices, adapt=True, lock=None)[source]#

Reads a list of devices minimising the number of connections to the client.

Parameters:
  • devices (list[str]) – The list of devices to read.

  • adapt (bool) – If possible, convert the value to real units.

  • lock (bool | None) – If True, lock the client while reading the devices. If None, use the default locking mechanism.

Returns:

read_values – A list of read values. If adapt=True, each element is a tuple of value and unit.

property devices#

Lists the devices connected across all modules.

class drift.drift.Module(name, drift, model=None, mode=None, channels=None, description='')[source]#

Bases: object

A Modbus module with some devices connected.

Parameters:
  • name (str) – A name to be associated with this module.

  • drift (Drift) – The instance of Drift used to communicate with the modbus network.

  • model (Optional[str]) – The Drift model for this module.

  • mode (Optional[str]) – The type of object (coil, discrete, input_register, or holding_register). If None, will be determined from the model, if possible. The mode of a module is only used if the Device does not specify its own mode since different devices in a module may have different modules.

  • channels (Optional[int]) – The number of channels in this module. If not provided it will be determined from model.

  • description (str) – A description or comment for this module.

add_device(name, address=None, device_class=None, **kwargs)[source]#

Adds a device

Parameters:
  • name (str | Device) – The name of the device. It is treated as case-insensitive. It can also be a Device instance, in which case the remaining parameters will be ignored.

  • device_class (Optional[Type[Device]]) – Either Device or a subclass of it.

  • kwargs – Other parameters to pass to Device.

  • address (Optional[int]) –

Return type:

Device

remove_device(name)[source]#

Removes a device.

Parameters:

name (str) – The name of the device to remove.

Return type:

Device

class drift.drift.Device(module, name, address, mode=None, channel=None, description='', offset=0.0, units=None, category=None, data_type=None, adaptor=None, adaptor_extra_params=())[source]#

Bases: object

A device connected to a modbus Module.

Parameters:
  • module (Module) – The Module to which this device is connected to.

  • name (str) – The name of the device. It is treated as case-insensitive.

  • address (int) – The address of the device. This is the address passed to pymodbus (i.e., the one encoded in the TCP packet) so it should have any offset already removed.

  • mode (Optional[str]) – The type of object (coil, discrete, input_register, or holding_register). If None, uses the mode from the module.

  • channel (Optional[int]) – For multi-bit registers, the bit to return. If None, returns the entire register value.

  • category (Optional[str]) – A user-defined category for this device. This can be used to group devices of similar type from different modules, for example 'temperature' to indicate temperature sensors.

  • offset (float) – A numerical offset to be added to the read value after running the adaptor.

  • description (str) – A description of the purpose of the device.

  • units (Optional[str]) – The units of the values returned, if an adaptor is used.

  • data_type (Optional[str]) – The data type for the raw values read. If not set, assumes unsigned integer. The format is the same as struct data types. For example, if reading a signed integer use h.

  • adaptor (Optional[str | dict | Callable]) – The adaptor to be used to convert read registers to physical values. It can be a string indicating one of the provided adaptors, a string with the format module1.module2:function (in this case module1.module2 will be imported and function will be called), a function or lambda function, or a mapping of register value to return value. If the adaptor is a function it must accept a raw value from the register and return the physical value, or a tuple of (value, unit).

  • adaptor_extra_params (tuple) – A tuple of extra parameters to be passed to the adaptor after the raw value.

async read(adapt=True, connect=True)[source]#

Reads the value of the coil or register.

If adapt=True and a valid adaptor was provided, the value returned is the one obtained after applying the conversion function to the raw register value. Otherwise returns the raw value.

If connect=False, the user is responsible for connecting and disconnecting. This is sometimes useful for bulk reading, when one does not want to recreate the socket for each device.

Returns:

read_value – A tuple in which the first element is the read raw or converted value and the second is the associated unit. If adapt=False no units are returned.

Return type:

tuple[int | float, None | str]

async write(value, connect=True)[source]#

Writes values to a coil or register.

Parameters:
  • value – The value to write to the device.

  • connect – Whether to connect to the client and disconnect after writing. If connect=False, the user is responsible for connecting and disconnecting. This is sometimes useful for bulk writing, when one does not want to recreate the socket for each device.

Return type:

bool

property client: AsyncModbusTcpClient | None#

Returns the pymodbus client.

class drift.drift.Relay(module, *args, relay_type='NC', **kwargs)[source]#

Bases: Device

A device representing a relay.

The main difference with a normal Device is that the adaptor is defined by the relay_type, which can be normally closed (NC) or normally open (NO).

Parameters:

module (Module) –

async close()[source]#

Closes the relay.

async cycle(delay=1)[source]#

Cycles a relay, waiting delay seconds.

async open()[source]#

Opens the relay.

async switch()[source]#

Switches the state of the relay.

Available adaptors#

drift.adaptors.flow(raw_value, meter_gain=1)[source]#

Flow meter conversion.

flow_rate = meter_gain * (raw_value - 3276) / 3276

drift.adaptors.linear(raw_value, min, max, range_min, range_max, unit=None)[source]#

A general adaptor for a linear sensor.

M = min + raw_value / (range_max - range_min) * (max - min)

drift.adaptors.proportional(raw_value, factor, unit=None)[source]#

Applies a proportional factor.

drift.adaptors.pwd(raw_value, unit=None)[source]#

Pulse Width Modulator (PWM) output.

The register is a 16-bit word as usual, but the module does not use the 5 LSBs. So, 10-bit resolution on the PWM value (MSB is for sign and is fixed). 0-100% duty cycle maps to 0 to 32736 decimal value in the register.

PWD = 100 * raw_value / (2**15 - 1)

drift.adaptors.rh_dwyer(raw_value)[source]#

Returns Dwyer sensor relative humidity (RH) from a raw register value.

Range is 0-100%.

drift.adaptors.rtd(raw_value)[source]#

Converts platinum RTD (resistance thermometer) output to degrees C.

The temperature resolution is 0.1C per ADU, and the temperature range is -273C to +850C. The 16-bit digital number wraps below 0C to 2^16-1 ADU. This handles that conversion.

drift.adaptors.rtd10(raw_value)[source]#

Convert platinum RTD output to degrees C.

The conversion is simply 0.1 * raw_value.

drift.adaptors.t_dwyer(raw_value)[source]#

Returns Dwyer sensor temperature from a raw register value.

Range is -30C to +70C.

drift.adaptors.voltage(raw_value, v_min=0, v_max=10, res=32760, gain=1)[source]#

Converts a raw value to a voltage measurement.

V = raw_value / res * (v_max - v_min) * gain