User Guide#
This guide covers common usage patterns and best practices for SECoP-Ophyd.
Device Connection#
Basic Connection#
Connect to a SECoP node using its IP address and port:
from ophyd_async.core import init_devices
from secop_ophyd.SECoPDevices import SECoPNodeDevice
with init_devices():
device = SECoPNodeDevice('192.168.1.100:10767')
The device tree is automatically built from the node’s descriptive data during the init_devices() context.
Important: The connection must be established within an init_devices() context manager, which handles the asynchronous connection setup.
Device Naming#
By default, devices use the SECoP node’s equipment_id property as their name. You can customize this:
# Default: uses equipment_id from SECoP node
device = SECoPNodeDevice('192.168.1.100:10767')
print(device.name) # e.g., "CRYO-01"
# Custom name
device = SECoPNodeDevice('192.168.1.100:10767', name='my_cryostat')
print(device.name) # "my_cryostat"
# With prefix (useful for multiple similar devices)
device = SECoPNodeDevice(
'192.168.1.100:10767',
prefix='beamline_A_',
name='cryo'
)
print(device.name) # "beamline_A_cryo"
Connection Parameters#
The SECoPNodeDevice constructor accepts the following parameters:
SECoPNodeDevice(
sec_node_uri: str, # Required: 'host:port' format
prefix: str = "", # Optional: prefix for device name
name: str = "", # Optional: custom device name
loglevel: str = "INFO", # Optional: logging level
logdir: str | None = None # Optional: log file directory
)
Parameters explained:
sec_node_uri(required): Connection string in'host:port'format'localhost:10767'- Local SECoP node'192.168.1.100:10767'- Network device'device.example.com:10767'- Domain name
prefix(optional): String prefix added to the device name. Useful when managing multiple devices.name(optional): Custom name for the device. If not provided, the device uses the SECoP node’sequipment_idproperty.loglevel(optional): Control logging verbosity. Options:"DEBUG","INFO","WARNING","ERROR","CRITICAL"logdir(optional): Directory path for storing log files. IfNone, the default logdir.secop-ophyd/is set.
Examples:
# Minimal connection
device = SECoPNodeDevice('192.168.1.100:10767')
# With custom name
device = SECoPNodeDevice('192.168.1.100:10767', name='cryo_controller')
# With prefix and logging to file
device = SECoPNodeDevice(
'192.168.1.100:10767',
prefix='lab1_',
loglevel='DEBUG',
logdir='/var/log/secop'
)
Logging Configuration#
SECoP-Ophyd provides default logging for debugging and monitoring. Logs are written to rotating hourly log files (48 hours retention).
Default Logging:
# Logs written to .secop-ophyd/secop-ophyd.log (default)
device = SECoPNodeDevice('localhost:10767')
# Change log level
device = SECoPNodeDevice('localhost:10767', loglevel="DEBUG")
Custom Log Directory:
# Write logs to custom directory
device = SECoPNodeDevice(
'localhost:10767',
loglevel="DEBUG",
logdir="/var/log/secop"
)
# Logs written to: /var/log/secop/secop-ophyd.log
Log File Behavior:
Default location:
.secop-ophyd/in current working directoryLog files rotate hourly with 48-hour retention
Multiple devices can share the same log file
Logs use UTC timestamps for consistency
Log Levels:
DEBUG: Detailed protocol messages, useful for troubleshootingINFO: General operational information (default)WARNING: Warning messages onlyERROR: Error messages onlyCRITICAL: Critical errors only
Device Structure#
Understanding the Hierarchy#
A SECoP-Ophyd device follows this structure:
SECoPNodeDevice (Node)
├── SECoPMoveableDevice (Module)
│ ├── parameter_a (Signal)
│ ├── parameter_b (Signal)
│ └── command_x (Method)
└── SECoPReadableDevice (Module)
├── parameter_c (Signal)
└── command_y (Method)
Example with a temperature controller:
device
├── temperature_controller
│ ├── value # Current temperature (readable)
│ ├── target # Target temperature (readable/writable)
│ ├── ramp # Ramp rate (readable/writable)
│ ├── status # Device status
│ └── reset() # Reset command
└── pressure_sensor
└── value # Current pressure (readable)
Working with Signals#
Reading Signals#
Read single values:
from bluesky.plan_stubs import rd
def read_temperature():
value = yield from rd(device.temperature.value)
print(f"Temperature: {value}")
return value
RE(read_temperature())
Setting Signals#
There’s an important distinction when setting values on movable devices (SECoP modules with the Drivable interface class):
Module-level vs Signal-level Setting
For a movable device like a temperature controller, you can set the target it should drive toward at two levels:
Module-level (preferred):
device.temperature_controller.set(value)Sets the target parameter AND waits for the device to reach the setpoint
Returns a status that completes only when the module returns to IDLE state
Use this when you want to wait for the operation to complete
Signal-level:
device.temperature_controller.target.set(value)Only sets the target parameter value
Returns immediately once the parameter is written
Use this for setting config parameters
Examples:
from bluesky.plan_stubs import mv, abs_set
# Module-level set - waits until temperature is reached
RE(abs_set(device.temperature_controller, 300))
# Signal-level set - returns immediately after setting target
RE(abs_set(device.temperature_controller.target, 300))
# Explicitly control wait behavior
RE(abs_set(device.temperature_controller, 300, wait=True)) # Wait for completion
RE(abs_set(device.temperature_controller, 300, wait=False)) # Don't wait
Note
For Drivable modules, prefer module-level set() to ensure operations complete before proceeding. For simple parameter updates on Readable or Writable modules, use signal-level set().
Using SECoP Commands#
Command Basics#
SECoP commands are wrapped as bluesky plans. Use RE() to execute them. Commands should return immediately.
For some long-running operations, the device will go into a BUSY state, and you may want to wait until the device returns to IDLE state.
# Call a command without arguments
RE(device.module.reset())
# Call a command with arguments
RE(device.module.configure(arg = {'mode': 'auto', 'setpoint': 100}))
All commands plans accept a wait_for_idle parameter:
# Return immediately (default)
RE(device.module.command(arg=value, wait_for_idle=False))
# Wait for device to return to IDLE state
RE(device.module.command(arg=value, wait_for_idle=True))
Note
For commands that trigger long operations, set wait_for_idle=True to ensure the plan waits until the operation completes.
Return Values#
Capture command return values:
def get_status():
result = yield from device.module.get_info()
print(f"Device info: {result}")
return result
info = RE(get_status())
Class File Generation#
Why Generate Class Files?#
While SECoP-Ophyd works without them, class files provide:
IDE autocompletion - See available modules and parameters
Type hints - Catch errors before runtime
Documentation - Understand device structure
Generating Class Files#
# Connect to device
with init_devices():
device = SECoPNodeDevice('localhost:10800')
# Generate class file in current directory
device.class_from_instance()
# Or specify output directory
device.class_from_instance('/path/to/output')
The generated code will look similar to the example below, with signals annotated with their types and commands as method stubs. Enums are generated for parameters with the SECoP enum type.
Enum classes are namespaced under the module device class and
parameter name. Should multipe enums have the same name, theyre members are merged,
and the class will be derived from SupersetEnum instead of StrictEnum.
from typing import Annotated as A
from ophyd_async.core import SignalR, SignalRW, StandardReadableFormat as Format, StrictEnum
from numpy import ndarray
from secop_ophyd.SECoPDevices import ParameterType as ParamT, PropertyType as PropT, SECoPMoveableDevice, SECoPNodeDevice
class Cryostat_Mode_Enum(StrictEnum):
"""mode enum for `Cryostat`."""
RAMP = "ramp"
PID = "pid"
OPENLOOP = "openloop"
class Cryostat(SECoPMoveableDevice):
"""A simulated cc cryostat with heat-load, specific heat for the sample and a temperature dependent heat-link between sample and regulation."""
# Module Properties
group: A[SignalR[str], PropT()]
description: A[SignalR[str], PropT()]
implementation: A[SignalR[str], PropT()]
interface_classes: A[SignalR[ndarray], PropT()]
features: A[SignalR[ndarray], PropT()]
# Module Parameters
value: A[SignalR[float], ParamT(), Format.HINTED_SIGNAL] # regulation temperature; Unit: (K)
status: A[SignalR[ndarray], ParamT()] # current status of the module
target: A[SignalRW[float], ParamT(), Format.HINTED_SIGNAL] # target temperature; Unit: (K)
ramp: A[SignalRW[float], ParamT()] # ramping speed of the setpoint; Unit: (K/min)
setpoint: A[SignalR[float], ParamT()] # current setpoint during ramping else target; Unit: (K)
mode: A[SignalRW[Cryostat_Mode_Enum], ParamT()] # mode of regulation
maxpower: A[SignalRW[float], ParamT()] # Maximum heater power; Unit: (W)
heater: A[SignalR[float], ParamT()] # current heater setting; Unit: (%)
heaterpower: A[SignalR[float], ParamT()] # current heater power; Unit: (W)
pid: A[SignalRW[ndarray], ParamT()] # regulation coefficients
p: A[SignalRW[float], ParamT()] # regulation coefficient 'p'; Unit: (%/K)
i: A[SignalRW[float], ParamT()] # regulation coefficient 'i'
d: A[SignalRW[float], ParamT()] # regulation coefficient 'd'
tolerance: A[SignalRW[float], ParamT()] # temperature range for stability checking; Unit: (K)
window: A[SignalRW[float], ParamT()] # time window for stability checking; Unit: (s)
timeout: A[SignalRW[float], ParamT()] # max waiting time for stabilisation check; Unit: (s)
class Cryo_7_frappy_demo(SECoPNodeDevice):
"""short description
This is a very long description providing all the gory details about the stuff we are describing."""
# Module Devices
cryo: Cryostat
# Node Properties
equipment_id: A[SignalR[str], PropT()]
firmware: A[SignalR[str], PropT()]
description: A[SignalR[str], PropT()]
_interfaces: A[SignalR[ndarray], PropT()]
Note
Annotations for signals include:
Signal type (e.g.,
SignalR,SignalRW)Signal data type (e.g.,
float,str,… (SignalDatatype)Format declares if the signal represents a read or a config signal (StandardReadableFormat)
SECoP attribute type
ParamTthe signal represents a SECoP parameter (data is dynamic at runtime) orPropTthe signal represents a SECoP property (static metadata at runtime, can only change on reconnect)
Using Generated Classes#
# Import generated classes
from genNodeClass import MyDevice
# Instatiate device using generated class
with init_devices():
cryo_node = MyDevice('localhost:10800')
# Now you have autocompletion!
cryo_node.cryo. # IDE shows: value, target, ramp, status, etc.
Warning
Regenerate class files whenever the static metadata of a SECoP node changes! This can happen on every new connection to a SEC node.
Best Practices#
Connection Management#
With or without a runengine involved always use init_devices() context manager:
# Good
with init_devices():
device = SECoPNodeDevice('localhost:10800')
# Device is properly connected and cleaned up
# Bad - no cleanup
device = SECoPNodeDevice('localhost:10800')
Wait Strategies#
Choose appropriate wait strategies for commands:
# Quick commands - no wait needed
RE(device.module.get_status(wait_for_idle=False))
# Long operations - wait for completion
RE(device.motor.move_to(100, wait_for_idle=True))
Advanced Topics#
Mixed Device Ecosystems#
Use SECoP devices alongside EPICS, Tango, or other protocols:
from ophyd import EpicsMotor
from ophyd_async.tango import TangoDevice
# Mix different device types
with init_devices():
secop_temp = SECoPNodeDevice('localhost:10800')
epics_motor = EpicsMotor('XF:31IDA-OP{Tbl-Ax:X1}Mtr', name='motor')
tango_detector = TangoDevice('sys/tg_test/1')
# Use together in plans
RE(scan([tango_detector], epics_motor, 0, 10, 11))
RE(mv(secop_temp.target, 300))