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. TheAsyncModbusTcpClient
object can be accessed asDrift.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
orDevice.write
, which handle the connection to the server.- Parameters:
- 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 matchesdevice
.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
.
- 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:
- Returns:
read_values – A dictionary of module-qualified device names and read values, along with their units.
- Return type:
- async read_devices(devices, adapt=True, lock=None)[source]#
Reads a list of devices minimising the number of connections to the client.
- Parameters:
- 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
, orholding_register
). IfNone
, will be determined from the model, if possible. Themode
of a module is only used if theDevice
does not specify its ownmode
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:
- 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
, orholding_register
). IfNone
, 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 useh
.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 casemodule1.module2
will be imported andfunction
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.
- 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:
- 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 therelay_type
, which can be normally closed (NC) or normally open (NO).- Parameters:
module (Module) –
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.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
.