How to use soft signals#
SoftSignalBackend provides a lightweight way to expose Python values and callables as ophyd-async signals, without implementing a full hardware backend. There are two broad usage patterns: pure soft signals (in-memory state only) and callable-backed signals (delegating to Python functions or coroutines).
Case A: Pure soft signals (no callable)#
Use case: a signal that holds a value in memory, with no hardware or external function involved. Useful for configuration parameters, simulated devices.
from ophyd_async.core import soft_signal_rw
# A read/write float signal, default value 0.0
exposure_time = soft_signal_rw(float, initial_value=0.1, units="s")
# A read/write enum signal
from enum import Enum
class Mode(Enum):
DARK = "dark"
LIGHT = "light"
mode = soft_signal_rw(Mode, initial_value=Mode.DARK)
Reads always return the last value written. No polling or external calls occur.
Case B: Single-value read/write with matching types#
Use Case: A callable with a single argument where the input and output types match the signal’s SignalDatatypeT (e.g., a motor position setter/getter).
Approach:
def read_position() -> float:
# returns current position
...
def move_to(position: float) -> float:
# Move hardware and return actual position
...
motor_position = soft_signal_rw(
float,
setter=move_to,
getter=read_position,
)
Rationale:
Directly wrap the callable in a
SoftSignalBackend-backed signal.Avoids the need for separate
Command+Signalpairs when types align.Preserves type hints and integrates seamlessly with scans.
Case C: Mismatched setter and getter types or multiple input types#
Use Case: A callable where the input type differs from the output (e.g., sending a config object but receiving a string status).
status = soft_signal_rw(str)
from ophyd_async.core import soft_command
async def configure_subsystem(*args, **kwargs) -> None:
# Apply config...
await status.set("configured")
config_cmd = soft_command(configure_subsystem)
await config_cmd.execute(...)
current_status = await status.get_value()
Rationale:
Use a
Commandto handle the mismatched input/output types.Store the result in a separate
Signal(here,status) for readability in plans.Ensures type safety:
Commandinput (MotorConfig) andSignaloutput (float) remain distinct.
Case D: Complex returns or multiple outputs#
Use Case: A callable returning structured data (e.g., a diagnostic function yielding many metrics).
Approach:
# Split outputs into individual signals
temp_signal = soft_signal_rw(float)
pressure_signal = soft_signal_rw(float)
def _diagnostics(): return 0.0, 1.0
async def run_diagnostics() -> None:
temp, pressure = _diagnostics()
await temp_signal.set(temp)
await pressure_signal.set(pressure)
diagnostics_cmd = soft_command(run_diagnostics)
result = await diagnostics_cmd.execute()
temp = await temp_signal.read()
pressure = await pressure_signal.read()
Rationale:
Prefer splitting outputs into discrete
Signals if they’re independently useful.For ad-hoc use, a
Commandsuffices, with manual extraction of results.Maintains separation of concerns: signals represent state, commands represent actions.
Key Takeaways:
Prioritize
SoftSignalBackendwith callables for simple, type-aligned read/write operations (Case B).Combine
Command+Signalwhen types diverge or actions yield secondary results (Cases C/D).Avoid overloading signals: If a callable performs an action and returns data, model the action as a
Commandand the data as one or moreSignals.Polling: Use
poll_periodinSoftSignalBackendfor live updates (e.g., sensor readings), but ensuregetteris lightweight.