Control-lab-ly
Lab Equipment Automation Package
Description
User-friendly package that simplifies the definition and control of reconfigurable setups for high-throughput experimentation and machine learning.
Installation
Control-lab-ly can be found on PyPI and can be easily installed with pip install
.
Quickstart
Import the desired class from the library and initialize to use.
from controllably.Move.Cartesian import Gantry
mover = Gantry(...)
mover.connect()
mover.safeMoveTo((x,y,z))
Explore the details for each object using the help()
function, or the ?
operator within the IPython / Jupyter Notebook environment.
Device support
- Make
- (QInstruments) BioShake Orbital Shaker
- (Arduino-based devices)
- Multi-channel LED array
- Multi-channel spin-coater
- Peltier device
- Measure
- (BioLogic) via
easy-biologic
(optional) - (Keithley) via
PyMeasure
(optional) - (Sentron) SI series pH meters
- (Arduino-based device)
- Precision mass balance
- Load cell
- (BioLogic) via
- Move
- (Creality) Ender-3
- (Dobot) with
external/../dobot_api
- M1 Pro
- MG400
- (Arduino-based device) gantry robot running on GRBL
- Transfer
- (Sartorius) rLINE® dispensing modules
- (TriContinent) C Series syringe pumps
- View
- (FLIR) AX8 thermal imaging camera via
pyModbusTCP
(optional) - (General) Web cameras with
cv2
- (FLIR) AX8 thermal imaging camera via
Advanced Usage
Setup initialization can be greatly simplified with Control-lab-ly.
To access files / folders in the project repository as you would with an installed package, use the init()
function to add the project directory into PATH.
from controllably import init
init('project_root')
from tools import ToolSetup01
setup = ToolSetup01.setup()
setup.MoverDevice.loadDeckFromFile(ToolSetup01.LAYOUT_FILE)
Here, the setup is initialized and returned with just ToolSetup01.setup()
, and the layout is loaded with the loadDeckFromFile
method.
Folder structure
To make full use of Control-lab-ly's features, a typical project file structure will need the library
and tools
folders.
project_root/
|
├── library/
| ├── deck/
| | ├── layout_board_30x30.json
| | └── layout_board_60x30.json
| ├── labware/
| | ├── generic_96_tiprack.json
| | ├── generic_8_wellplate.json
| | └── generic_1_bin.json
| ├── plugins/
| | ├── tool_part_1.py
| | ├── tool_part_2.py
| | └── mock_module.py
| └── __init__.py
|
├── tools/
| ├── ToolSetup01/
| | ├── __init__.py
| | ├── config.yaml
| | └── layout.json
| ├── ToolSetup02/
| | ├── __init__.py
| | ├── config.yaml
| | └── layout.json
| ├── __init__.py
| └── registry.yaml
|
├── scripts/
| ├── experiment_script_1.py
| ├── experiment_2.ipynb
| └── ...
└── ...
Use start_project_here(target_dir)
to generate the above file structure,
or the CLI to create the required directories.
1. Features
For more advanced uses, Control-lab-ly provides a host of tools to streamline the development of lab equipment automation. This includes setting up configuration files and writing plugins.
- Dynamic object initialization
- Reconfigurable complex tools
- Modular positioning system
- Application and network interoperability
1.1 Dynamic object initialization
Control-lab-ly allows users to store all their tool configuration data in a YAML file, providing a single source of truth for all projects using the same set up. The config.yaml
file stores the configuration for all the tools in the set up, which can be parsed by Control-lab-ly to initialize the tools using get_setup()
.
MyDevice: # user-defined name
module: controllably.Move.Cartesian # "from" ...
class: Gantry # "import" ...
settings:
port: COM1 # serial port address
setting_A: [300,0,200]
setting_B: [[0,1,0],[-1,0,0]]
A different serial port address or camera index may be used by different machines for the same device.
See Section 2.1 to find out how to manage the different addresses used by different machines.
1.2 Reconfigurable complex tools
Compound devices are similarly configured in the config.yaml
file. The configuration details of the component tools are nested in details
.
MyCompoundDevice: # user-defined name
module: controllably.Compound.LiquidMover
class: LiquidMover
settings: # settings for compound device
speed_factor_lateral: null
speed_factor_up: 0.2
speed_factor_down: 0.2
speed_factor_pick_tip: 0.01
tip_approach_distance: 20
details: # nest component configuration in "details"
mover: # component name (defined in LiquidMover)
module: controllably.Move.Cartesian
class: Gantry
settings:
port: COM1
liquid: # component name (defined in LiquidMover)
module: controllably.Transfer.Liquid.Pipette.Sartorius
class: Sartorius
settings:
port: COM22
Lastly, you can define shortcuts (or aliases) at the end of config.yaml
to easily access the nested components of compound devices.
1.3 Modular positioning system
Control-lab-ly allows users to easily combine multiple modules and switch between local and global coordinates. The layout.json
file stores the layout configuration of your physical workspace (Deck
).
Optional: if your setup does not involve moving objects around in a pre-defined workspace, a layout configuration may not be required
{
"metadata": {
"displayName": "Example Layout (main)",
"displayCategory": "deck",
"displayVolumeUnits": "µL",
"displayLengthUnits": "mm",
"tags": []
},
"dimensions": [600,300,0],
"cornerOffset": [0,0,0],
"orientation": [0,0,0],
"slots": {
"1": {
"name": "slotOne",
"dimensions": [127.76,85.48,0],
"cornerOffset": [160.5,6.5,0],
"orientation": [0,0,0]
},
"2": {
"name": "slotTwo",
"dimensions": [127.76,85.48,0],
"cornerOffset": [310.5,6.5,0],
"orientation": [0,0,0],
"labware_file": "project_root/library/labware/labware_wellplate.json"
},
"3": {
"name": "slotThree",
"dimensions": [127.76,85.48,0],
"cornerOffset": [460.5,6.5,0],
"orientation": [0,0,0]
}
},
"zones":{
"A":{
"dimensions": [600,300,0],
"cornerOffset": [600,600,0],
"orientation": [-90,0,0],
"deck_file": "project_root/library/deck/layout_sub.json",
"entry_waypoints": [
[653.2, 224.6, 232]
]
}
}
}
The size and position of the Deck
is defined by the dimensions
, and combination of cornerOffset
and orientation
respectively.
dimensions
is the (x,y,z) dimensions with respect to the deck's own coordinate system.cornerOffset
is the (x,y,z) coordinates of the bottom-left corner of the deck with respect to world coordinates (typically the origin).orientation
is the (rz,ry,rx) rotation of the deck about the bottom-left corner with respect to world coordinates (typically the identity rotation or zero rotation).
Within the deck, slots
and zones
can be defined.
slots
are spaces where Labware can be placed. These Labware can be individual tools or vessel holders. Indexing of slots increments numerically, typically starting from 1.zones
are regions of nested layouts. As such, aDeck
of a smaller modular setup layout can be incorporated as part of a larger layout. Indexing of zones increments alphabetically, typically starting with 'A'.
Here, the dimensions
, cornerOffset
, and orientation
definitions apply similarly, except the latter two takes reference from the parent's origin and orientation. The filename definition in labware_file
and deck_file
can either be absolute filepaths, or relative to the project repository.
This package uses the same Labware files as those provided by Opentrons, which can be found here, and custom Labware files can be created here. Additional fields can be added to the these Labware files to enable features such as plate stacking and collision avoidance.
parameters.isStackable
is a boolean value defining if another Labware can be stacked above.slotAbove
defines a new slot above the Labware, with similar subfieldsslotAbove.name
,slotAbove.dimensions
,slotAbove.cornerOffset
, andslotAbove.orientation
.exclusionBuffer
is the offset from the lower and upper bounds of the Labware bounding box. i.e. [ [left, front, bottom], [right, back, top] ]WARNING: avoidance checks only apply to destination coordinates. Does not guarantee collision avoidance along intermediate path coordinates when using point-to-point move actions such as
move
,moveBy
ormoveTo
. UsesafeMoveTo
instead.
For zones, entry_waypoints
lists a sequence of coordinates that defines a safe path a translation tool can take to transit into that particular zone.
1.4 Application and network interoperability
To allow control of the setups over the network, or with other applications, Control-lab-ly provides a way to access the attributes and methods over a communication layer. A Controller
encodes and decodes requests and responses using an Interpreter
, serializing the data to be sent.
from controllably.core.control import Controller
from controllably.core.interpreter import JSONInterpreter
# 'model' controllers receives requests, triggers execution in registered objects,
# and transmits the resultant data
worker = Controller(role='model', interpreter=JSONInterpreter())
worker.setAddress('WORKER')
# 'view' controllers transmits requests and receives the resultant data
user = Controller(role='view', interpreter=JSONInterpreter())
user.setAddress('USER')
Each controller subscribes to one or more callbacks that will be called when the controller transmits. In this example, when user
tries to transmit a request to target controller ('WORKER'
), it will call worker.receiveRequest
. Likewise, when worker
tries to transmit data back to the request originator ('USER'
), it will call user.receiveData
.
# request flow: USER -> WORKER
user.subscribe(callback=worker.receiveRequest, callback_type='request', address='WORKER')
# data flow: USER -> WORKER
worker.subscribe(callback=user.receiveData, callback_type='data', address='USER')
A hub-and-spoke network can also be achieved using a new 'relay' controller.
# 'relay' controllers bridges communication between `model` and `view` controllers
hub = Controller(role='relay', interpreter=JSONInterpreter())
hub.setAddress('HUB')
# request flow: USER -> HUB -> WORKER
user.subscribe(callback=hub.relayRequest, callback_type='request', address='HUB', relay=True)
hub.subscribe(callback=worker.receiveRequest, callback_type='request', address='WORKER')
# data flow: WORKER -> HUB -> USER
worker.subscribe(callback=hub.relayData, callback_type='data', address='HUB', relay=True)
hub.subscribe(callback=user.receiveData, callback_type='data', address='USER')
These callbacks should be replaced with user implementation of communication layers, (e.g. socket communication or FastAPI).
2. Additional features
2.1 Managing hardware addresses
Hardware addresses may vary from machine to machine, especially for serial ports and cameras. To keep track of all the different port addresses, the machine ID and its corresponding port addresses are stored in registry.yaml
In the tools
folder, a template of registry.yaml
has been added to manage the machine-specific addresses of your connected devices (e.g. serial port and camera index). First, use the get_node
and get_ports
functions to identify your machine's ID and the serial port addresses of your tools.
from controllably.core.connection import get_node, get_ports
get_node() # Get the unique identifier of your machine
get_ports() # Get a list of serial port addresses of your connect devices
Next, populate the registry.yaml
file with the relevant information.
'012345678901234': # insert your machine's unique identifier
cam_index: # camera index of the connected imaging devices
__cam_01__: 1 # NOTE: retain leading and trailing double underscores
port: # addresses of serial ports
__MyDevice__: COM1 # NOTE: retain leading and trailing double underscores
Lastly, change the value for the serial port address in the config.yaml
file(s) to match the registry.
MyDevice: # user-defined name
module: controllably.Move.Cartesian # "from" ...
class: Gantry # "import" ...
settings:
port: __MyDevice__ # serial port address
setting_A: [300,0,200]
setting_B: [[0,1,0],[-1,0,0]]
2.2 Linting and coding assists
To help with development, linters such as Pylance provide suggestions while coding, based on the types of the objects. To make use of this feature, furnish the __init__.py
file with the corresponding tool names and classes from the config.yaml
file.
from dataclasses import dataclass
...
# ========== Optional (for typing) ========== #
from controllably.Compound.LiquidMover import LiquidMover
from controllably.Transfer.Liquid.Pipette.Sartorius import Sartorius
from controllably.Move.Cartesian import Gantry
@dataclass
class Platform:
MyCompoundDevice: LiquidMover
LiquidDevice: Sartorius
MoverDevice: Gantry
# ========================================== #
...
More additional features to be documented...
Dependencies
- matplotlib (>=3.9.2)
- numpy (>=2.1.0)
- opencv-python (>=4.11.0.86)
- pandas (>=2.2.2)
- parse (>=1.20.2)
- pyserial (>=3.5)
- PyYAML (>=6.0.1)
- scipy (>=1.14.1)
- pyModbusTCP (>=0.2.0)
- easy-biologic (>=0.4.0)
- nest-asyncio (>=1.6.0)
- setuptools (>=71.0.3)
- PyMeasure (>=0.15.0)
Contributors
@kylejeanlewis / @Quijanove / @mat-fox
How to Contribute
Issues and feature requests are welcome!
License
This project is distributed under the MIT License.