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’s equipment_id property.

  • loglevel (optional): Control logging verbosity. Options: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"

  • logdir (optional): Directory path for storing log files. If None, 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 directory

  • Log 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 troubleshooting

  • INFO: General operational information (default)

  • WARNING: Warning messages only

  • ERROR: Error messages only

  • CRITICAL: 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:

  1. 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

  2. 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 ParamT the signal represents a SECoP parameter (data is dynamic at runtime) or PropT the 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))

Further Resources#