-
Notifications
You must be signed in to change notification settings - Fork 40
SoftSignalBackend wrapping arbitrary callables #1280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b8888c5
15c67dc
6e90c40
b775f47
5713069
fbcb573
54abd02
f2d0e65
516733e
aa5b931
7207094
ed6214f
718f8fc
23f5fb9
d703fb4
25529e6
b31f2b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # 19. SoftSignalBackend Wrapping Arbitrary Callables | ||
|
|
||
| ## **Status** | ||
| Accepted | ||
|
|
||
|
|
||
| ## **Context** | ||
| Users working outside of EPICS/Tango ecosystems such as those wrapping third-party Python APIs, calling analysis scripts, or interfacing with devices with their own Python drivers, currently have no supported path to create signals without writing a full custom `SignalBackend`. This was identified as a friction point for smaller lab-based groups during the Bluesky community workshop. | ||
|
|
||
| To address this, `SoftSignalBackend` was extended to support arbitrary callables for getting, setting, and polling values. This allows users to integrate external systems without implementing a full backend, reducing boilerplate and improving usability. | ||
|
|
||
| ## **Decision** | ||
|
|
||
| ### **Extend `SoftSignalBackend` with Callable Support** | ||
| `SoftSignalBackend` was augmented with three new **keyword-only** parameters: | ||
| 1. **`getter`**: A callable (`Callable[[], T | Awaitable[T]]`) invoked during `get_value()` and `get_reading()` to fetch the current value from an external source. If `poll_period` is set, the `getter` is called periodically while a subscription is active. | ||
| 2. **`setter`**: A callable (`Callable[[SignalDatatypeT], SignalDatatypeT | None | Awaitable[SignalDatatypeT | None]]`) invoked during `put()`. It may return a `SignalDatatypeT`; if it returns `None`, the `getter` (if configured) is called immediately to refresh the cache. | ||
| 3. **`poll_period`**: A float representing the interval (in seconds) at which the `getter` is polled while a subscription is active. Requires `getter` to be set. | ||
|
|
||
| ### **Design Choices** | ||
| - All three parameters are **optional**. If none are provided, behavior remains identical to the existing `SoftSignalBackend`. | ||
| - The internal `self._reading` store remains the **single source of truth**. The `getter` updates this store rather than bypassing it, preserving coherence for subscriptions and cached reads. | ||
| - The `put` method accepts `SignalDatatypeT` (the same type as the signal's stored value) to maintain type safety and consistency. | ||
| - Polling tasks are used for subscriptions, starting in `set_callback` and canceling when subscriptions end. | ||
| - `get_setpoint()` **does not invoke the `getter`**; it returns the last value written to the `setter` or the initial value of. | ||
|
|
||
| ### **Factory Function Updates** | ||
| The convenience functions `soft_signal_rw` and `soft_signal_r_and_setter` were updated to accept `getter`, and `poll_period` arguments. `soft_signal_rw` additionally accepts a `setter` argument. These additional arguments are passed to `SoftSignalBackend`. | ||
|
|
||
| ## **Consequences** | ||
| **Improved Usability**: | ||
| Non-EPICS/Tango users can now easily integrate external systems (e.g., Python APIs, scripts) without writing full backends. | ||
|
|
||
| **No Breaking Changes**: | ||
| Existing code continues to work unchanged since all new parameters are optional. | ||
|
|
||
| **Consistent Behavior**: | ||
| Polling, caching, and subscriptions align with EPICS/Tango backend patterns. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # 20. How to use soft signals | ||
|
|
||
| The introduction of callable-backed `SoftSignalBackend` enables users to integrate non-EPICS/Tango systems (e.g., Python APIs, scripts, or custom hardware drivers) into ophyd-async without writing full `SignalBackend` implementations. Below are idiomatic patterns for common scenarios, balancing simplicity and type safety. | ||
|
|
||
| ## Case A: 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**: | ||
| ```python | ||
| 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` + `Signal` pairs when types align. | ||
| - Preserves type hints and integrates seamlessly with scans. | ||
|
|
||
| ## Case B: 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). | ||
|
|
||
| ```python | ||
| status = soft_signal_rw(str) | ||
| 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.read() | ||
| ``` | ||
| **Rationale**: | ||
| - Use a **`Command`** to handle the mismatched input/output types. | ||
| - Store the result in a separate `Signal` (here, `status`) for readability in plans. | ||
| - Ensures type safety: `Command` input (`MotorConfig`) and `Signal` output (`float`) remain distinct. | ||
|
|
||
| ## Case C: Complex returns or multiple outputs | ||
|
|
||
| **Use Case**: A callable returning structured data (e.g., a diagnostic function yielding many metrics). | ||
|
|
||
| **Approach**: | ||
| ```python | ||
| # Split outputs into individual signals | ||
| temp_signal = soft_signal_rw(float, getter=lambda: run_diagnostics()["temperature"]) | ||
| pressure_signal = soft_signal_rw(float, getter=lambda: run_diagnostics()["pressure"]) | ||
| 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 `Signal`s if they're independently useful. | ||
| - For ad-hoc use, a **`Command`** suffices, with manual extraction of results. | ||
| - Maintains separation of concerns: signals represent *state*, commands represent *actions*. | ||
|
|
||
| **Key Takeaways**: | ||
| 1. **Prioritize `SoftSignalBackend` with callables** for simple, type-aligned read/write operations (Case A). | ||
| 2. **Combine `Command` + `Signal`** when types diverge or actions yield secondary results (Cases B/C). | ||
| 3. **Avoid overloading signals**: If a callable performs an action *and* returns data, model the action as a `Command` and the data as one or more `Signal`s. | ||
| 4. **Polling**: Use `poll_period` in `SoftSignalBackend` for live updates (e.g., sensor readings), but ensure `getter` is lightweight. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,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, | ||
|
|
@@ -353,6 +353,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, | ||
| ) -> SignalRW[SignalDatatypeT]: | ||
| """Create a read-writable Signal with a [](#SoftSignalBackend). | ||
|
|
||
|
|
@@ -363,8 +367,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 | ||
|
|
||
|
|
@@ -375,6 +397,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 on lines
+400
to
+402
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The documentation above says that the setter method should be available here. Is this a mistake that it's left out?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was a mistake in the explanatory document. |
||
| ) -> tuple[SignalR[SignalDatatypeT], Callable[[SignalDatatypeT], None]]: | ||
| """Create a read-only Signal with a [](#SoftSignalBackend). | ||
|
|
||
|
|
@@ -386,9 +411,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) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assuming you're using AI to write this, please could you turn this into a "How to use soft signals" guide?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The format is AI but otherwise I wrote it.
I renamed the file to "how to use soft signals". Did you want any other changes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant put it in the
docs/how-to, and add a section on using soft signals (without callables) as ways to store data. Then the rest of the document you wrote follows that says how to add callables to make the set/get have side effects and introducing soft command too.