Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b8888c5
First draft for callable backed soft signals
burkeds Jun 3, 2026
15c67dc
Fixed docs. setter in SoftSignalBackend not handles Any
burkeds Jun 3, 2026
6e90c40
Modified soft signal factories
burkeds Jun 3, 2026
b775f47
Reviewer changes
burkeds Jun 3, 2026
5713069
Using maybe_await from bluesky.utils
burkeds Jun 3, 2026
fbcb573
Merge branch 'main' into callable_soft_signal_backend
burkeds Jun 3, 2026
54abd02
variable name change
burkeds Jun 4, 2026
f2d0e65
removed some unnecessary checks of _getter
burkeds Jun 4, 2026
516733e
Corrected typehinting of setter
burkeds Jun 5, 2026
aa5b931
Merge branch 'main' into callable_soft_signal_backend
burkeds Jun 5, 2026
7207094
Setpoint and _setter now take converted values
burkeds Jun 8, 2026
ed6214f
put setpoint in set_value
burkeds Jun 8, 2026
718f8fc
renamed explanation doc 20.
burkeds Jun 8, 2026
23f5fb9
Merge branch 'main' into callable_soft_signal_backend
burkeds Jun 8, 2026
d703fb4
fixing docs
burkeds Jun 8, 2026
25529e6
linting
burkeds Jun 8, 2026
b31f2b1
fixed error. soft_signal_r_and_setter is read-only and does not take …
burkeds Jun 9, 2026
e57dd39
Merge branch 'main' into callable_soft_signal_backend
burkeds Jun 12, 2026
1a640af
added how-to-use-soft-signals document
burkeds Jun 12, 2026
d57827c
linting
burkeds Jun 12, 2026
f6fd206
Potential fix for pull request finding
burkeds Jun 15, 2026
3faceea
Potential fix for pull request finding
burkeds Jun 15, 2026
c582e1c
Potential fix for pull request finding
burkeds Jun 15, 2026
ac9451f
fixed case D example code
burkeds Jun 15, 2026
389a38f
The setpoint should not be set again in set_value. This causes _updat…
burkeds Jun 15, 2026
d85bd43
Potential fix for pull request finding
burkeds Jun 15, 2026
4a67c39
fixing docs
burkeds Jun 15, 2026
00b80a6
Merge branch 'callable_soft_signal_backend' of https://github.com/blu…
burkeds Jun 15, 2026
ac0fe19
removed some comments I accidentally committed
burkeds Jun 16, 2026
ca35065
Merge branch 'main' into callable_soft_signal_backend
coretl Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ def setup(app: application.Sphinx):
"ophyd_async.core._mock_signal_backend.MockPutCallback",
"ophyd_async.core._protocol.C",
"ophyd_async.core._signal_backend.SignalDatatypeV",
"ophyd_async.core._soft_signal_backend.Getter",
"ophyd_async.core._soft_signal_backend.Setter",
"ophyd_async.core._status.AsyncStatusBase",
"ophyd_async.core._utils.P",
"ophyd_async.core._utils.T",
Expand Down
44 changes: 41 additions & 3 deletions src/ophyd_async/core/_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from ._mock_signal_backend import MockSignalBackend
from ._protocol import AsyncReadable, AsyncStageable
from ._signal_backend import SignalBackend, SignalDatatypeT, SignalDatatypeV
from ._soft_signal_backend import SoftSignalBackend
from ._soft_signal_backend import Getter, Setter, SoftSignalBackend
from ._status import AsyncStatus
from ._utils import (
CALCULATE_TIMEOUT,
Expand Down Expand Up @@ -337,6 +337,10 @@ def soft_signal_rw(
name: str = "",
units: str | None = None,
precision: int | None = None,
*,
getter: Getter[SignalDatatypeT] | None = None,
setter: Setter[Any] | None = None,
poll_period: float | None = None,
Comment thread
Copilot marked this conversation as resolved.
) -> SignalRW[SignalDatatypeT]:
"""Create a read-writable Signal with a [](#SoftSignalBackend).

Expand All @@ -347,8 +351,26 @@ def soft_signal_rw(
:param name: The name of the signal.
:param units: The units of the signal.
:param precision: The precision of the signal.
:param getter:
Optional callable returning the current device value, called on
get_value/get_reading and periodically if poll_period is set.
:param setter:
Optional callable performing the set action. May return the settled
value; if it returns None and a getter is configured, the getter is
called to refresh the cache.
:param poll_period:
How often (seconds) to call the getter while a subscription is active.
Requires getter to be set.
"""
backend = SoftSignalBackend(datatype, initial_value, units, precision)
backend = SoftSignalBackend(
datatype,
initial_value,
units,
precision,
getter=getter,
setter=setter,
poll_period=poll_period,
)
signal = SignalRW(backend=backend, name=name)
return signal

Expand All @@ -359,6 +381,9 @@ def soft_signal_r_and_setter(
name: str = "",
units: str | None = None,
precision: int | None = None,
*,
getter: Getter[SignalDatatypeT] | None = None,
poll_period: float | None = None,
Comment thread
burkeds marked this conversation as resolved.
) -> tuple[SignalR[SignalDatatypeT], Callable[[SignalDatatypeT], None]]:
"""Create a read-only Signal with a [](#SoftSignalBackend).

Expand All @@ -370,9 +395,22 @@ def soft_signal_r_and_setter(
:param name: The name of the signal.
:param units: The units of the signal.
:param precision: The precision of the signal.
:param getter:
Optional callable returning the current device value, called on
get_value/get_reading and periodically if poll_period is set.
:param poll_period:
How often (seconds) to call the getter while a subscription is active.
Requires getter to be set.
:return: A tuple of the created SignalR and a callable to set its value.
"""
backend = SoftSignalBackend(datatype, initial_value, units, precision)
backend = SoftSignalBackend(
datatype,
initial_value,
units,
precision,
getter=getter,
poll_period=poll_period,
)
signal = SignalR(backend=backend, name=name)
return (signal, backend.set_value)

Expand Down
75 changes: 66 additions & 9 deletions src/ophyd_async/core/_soft_signal_backend.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
from __future__ import annotations

import asyncio
import time
import typing
from abc import abstractmethod
from collections.abc import Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Generic, get_args

import numpy as np
from bluesky.protocols import Reading
from bluesky.utils import maybe_await
from event_model import DataKey

from ._datatypes import Table
Expand All @@ -29,9 +31,6 @@


class SoftConverter(Generic[SignalDatatypeT]):
# This is Any -> SignalDatatypeT because we support coercing
Comment thread
burkeds marked this conversation as resolved.
# value types to SignalDatatype to allow people to do things like
# SignalRW[Enum].set("enum value")
@abstractmethod
def write_value(self, value: Any) -> SignalDatatypeT: ...

Expand Down Expand Up @@ -114,6 +113,14 @@ def make_converter(datatype: type[SignalDatatype]) -> SoftConverter:
raise TypeError(f"Can't make converter for {datatype}")


Setter = (
Callable[[SignalDatatypeT | None], SignalDatatypeT | None]
| Callable[[SignalDatatypeT | None], Awaitable[SignalDatatypeT | None]]
| None
)
Getter = Callable[[], SignalDatatypeT | Awaitable[SignalDatatypeT]]

Comment thread
burkeds marked this conversation as resolved.

class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
"""An backend to a soft Signal, for test signals see [](#MockSignalBackend).

Expand All @@ -124,6 +131,16 @@ class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
:param units: The units for numeric datatypes.
:param precision:
The number of digits after the decimal place to display for a float datatype.
:param getter:
Optional callable returning the current device value, called on
get_value/get_reading and periodically if poll_period is set.
:param setter:
Optional callable performing the set action. May return the settled
value; if it returns None and a getter is configured, the getter is
called to refresh the cache.
:param poll_period:
How often (seconds) to call the getter while a subscription is active.
Requires getter to be set.
"""

def __init__(
Expand All @@ -132,18 +149,41 @@ def __init__(
initial_value: SignalDatatypeT | None = None,
units: str | None = None,
precision: int | None = None,
*,
getter: Getter[SignalDatatypeT] | None = None,
setter: Setter[SignalDatatypeT] | None = None,
poll_period: float | None = None,
):
# Create the right converter for the datatype
if poll_period is not None and getter is None:
raise ValueError("poll_period requires a getter to be set")
self.converter = make_converter(datatype or float)
# Add the extra static metadata to the dictionary
self.metadata = make_metadata(datatype, units, precision)
# Create and set the initial value
self.initial_value = self.converter.write_value(initial_value)
self.reading: Reading[SignalDatatypeT]
self.callback: Callback[Reading[SignalDatatypeT]] | None = None
self._getter = getter
self._setter = setter
self._poll_period = poll_period
self._poll_task: asyncio.Task | None = None
self.set_value(self.initial_value)
super().__init__(datatype)

async def _update_value_from_getter(self) -> SignalDatatypeT | None:
if self._getter is None:
return
result = await maybe_await(self._getter())
self.set_value(result)

async def _poll(self) -> None:
if self._poll_period is None:
raise RuntimeError("No poll_period configured")
while True:
await asyncio.sleep(self._poll_period)
try:
await self._update_value_from_getter()
except Exception:
continue

def set_value(self, value: SignalDatatypeT):
"""Set the current value, alarm and timestamp."""
self.reading = Reading(
Expand All @@ -160,19 +200,30 @@ def source(self, name: str, read: bool) -> str:
async def connect(self, timeout: float):
pass

async def put(self, value: SignalDatatypeT | None) -> None:
async def put(self, value: Any) -> None:
write_value = self.initial_value if value is None else value
self.set_value(write_value)
if self._setter is not None:
written_value = await maybe_await(self._setter(value))
if written_value is not None:
self.set_value(written_value)
elif self._getter is not None:
await self._update_value_from_getter()
else:
self.set_value(write_value)
else:
self.set_value(write_value)

async def get_datakey(self, source: str) -> DataKey:
return make_datakey(
self.datatype or float, self.reading["value"], source, self.metadata
)

async def get_reading(self) -> Reading[SignalDatatypeT]:
await self._update_value_from_getter()
return self.reading

async def get_value(self) -> SignalDatatypeT:
await self._update_value_from_getter()
return self.reading["value"]

async def get_setpoint(self) -> SignalDatatypeT:
Expand All @@ -184,4 +235,10 @@ def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> N
raise RuntimeError("Cannot set a callback when one is already set")
if callback:
callback(self.reading)
if self._poll_period is not None:
self._poll_task = asyncio.create_task(self._poll())
else:
if self._poll_task is not None:
self._poll_task.cancel()
self._poll_task = None
self.callback = callback
86 changes: 86 additions & 0 deletions tests/unit_tests/core/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1099,3 +1099,89 @@ def unsubscribe_if_one(value):
call({"": {"value": 1.0, "timestamp": ANY, "alarm_severity": 0}}),
call({"": {"value": 2.0, "timestamp": ANY, "alarm_severity": 0}}),
]


async def test_soft_signal_rw_with_getter():
store = [0.0]
signal = soft_signal_rw(float, getter=lambda: store[0])
await signal.connect()
store[0] = 42.0
assert await signal.get_value() == pytest.approx(42.0)


async def test_soft_signal_rw_with_setter():
store = [0.0]
signal = soft_signal_rw(float, setter=lambda v: store.__setitem__(0, v))
await signal.connect()
await signal.set(7.0)
assert store[0] == pytest.approx(7.0)


async def test_soft_signal_rw_with_getter_and_setter():
store = [0.0]
signal = soft_signal_rw(
float,
setter=lambda v: store.__setitem__(0, v),
getter=lambda: store[0],
)
await signal.connect()
await signal.set(3.0)
store[0] = 99.0 # external change
assert await signal.get_value() == pytest.approx(99.0)


async def test_soft_signal_rw_with_poll_period():
store = [0.0]
signal = soft_signal_rw(float, getter=lambda: store[0], poll_period=0.05)
await signal.connect()

updates: asyncio.Queue = asyncio.Queue()
signal.subscribe_reading(updates.put_nowait)

await updates.get() # consume initial

store[0] = 5.0
reading = await asyncio.wait_for(updates.get(), timeout=1.0)
assert reading[signal.name]["value"] == pytest.approx(5.0)

signal.clear_sub(updates.put_nowait)


async def test_soft_signal_rw_poll_period_without_getter_raises():
with pytest.raises(ValueError, match="poll_period requires a getter"):
soft_signal_rw(float, poll_period=0.1)


async def test_soft_signal_r_and_setter_with_getter():
store = [0.0]
signal, set_value = soft_signal_r_and_setter(float, getter=lambda: store[0])
await signal.connect()
store[0] = 42.0
assert await signal.get_value() == pytest.approx(42.0)
# set_value still works independently of the getter
set_value(99.0)
assert signal._connector.backend.reading["value"] == pytest.approx(99.0)


async def test_soft_signal_r_and_setter_with_poll_period():
store = [0.0]
signal, _ = soft_signal_r_and_setter(
float, getter=lambda: store[0], poll_period=0.05
)
await signal.connect()

updates: asyncio.Queue = asyncio.Queue()
signal.subscribe_reading(updates.put_nowait)

await updates.get() # consume initial

store[0] = 7.0
reading = await asyncio.wait_for(updates.get(), timeout=1.0)
assert reading[signal.name]["value"] == pytest.approx(7.0)

signal.clear_sub(updates.put_nowait)


async def test_soft_signal_r_and_setter_poll_period_without_getter_raises():
with pytest.raises(ValueError, match="poll_period requires a getter"):
soft_signal_r_and_setter(float, poll_period=0.1)
Loading
Loading