From b289e4077f5a6d73c1ac7f5781699feaa7669aca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:19:28 +0000 Subject: [PATCH 01/36] docs: ADR 13 - declarative/procedural split and ControllerFiller --- ...-procedural-split-and-controller-filler.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md new file mode 100644 index 000000000..9e2578865 --- /dev/null +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -0,0 +1,158 @@ +# 13. Declarative/Procedural Split and ControllerFiller + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +FastCS currently has two mechanisms for declaring the shape of a `Controller`: + +1. **Class-scope `Attribute` instances** (`ramp_rate = AttrRW(Float(), io_ref=...)` + assigned directly in the class body). `BaseController._bind_attrs` + (`src/fastcs/controllers/base_controller.py`) walks the MRO, finds these, and + `deepcopy`s each one onto the instance so that multiple instances of the same + `Controller` subclass do not share mutable state. +2. **Bare type hints** (`frames: AttrRW[int]`) validated, not created, by + `HintedAttribute` (`src/fastcs/attributes/hinted_attribute.py`) via + `_find_type_hints`/`_validate_type_hints`. The actual `Attribute` must be + constructed and assigned by the developer, normally in an `initialise()` + override that introspects a device. + +ophyd-async has the equivalent split (`Device` class-body hints vs. `__init__` +procedural construction, see `docs/explanations/declarative-vs-procedural.md`), +but only one mechanism for the declarative half: hints always *create* children, +provisioned by a `DeviceConnector`-owned `DeviceFiller` +(`ophyd_async/core/_device_filler.py`) either immediately or later via +connect-time introspection. There is no ophyd-async equivalent of FastCS's +class-scope instance style, and there cannot be — a `Signal`'s backend depends on +which `DeviceConnector` the owning `Device` is constructed with, so the backend +cannot be known until connect/construction time chooses the connector. + +Our own downstream drivers show why the FastCS class-scope-instance mechanism is +already the minority case in practice, not the norm: + +- `fastcs-eiger` mixes class-body instances (`trigger_exposure = AttrRW(Float())`) + with bare hints filled by REST-API introspection in `initialise()` + (`eiger_detector_controller.py`) — i.e. it already wants one unified mechanism. +- `fastcs-secop`, `fastcs-PandABlocks`, and `fastcs-catio`'s dynamic path build + **all** of their attributes from wire/YAML-derived data at `initialise()` time; + none of them use class-scope instances at all. + +The `deepcopy` half of `_bind_attrs` exists solely to make class-scope instances +safe to reuse across `Controller` instances. It is fragile (IO objects, bound +callbacks, and connections do not always survive a deepcopy cleanly) and costs +construction time on every instantiation, for a feature none of our real-world +introspecting drivers use. + +## Decision + +Adopt a single declarative mechanism, matching ophyd-async: **class body = +bare type hints only; instance scope = procedural construction.** Concretely: + +- Remove class-scope `Attribute` **instances** entirely. `AttrRW(Float(), + io=...)` may no longer be assigned directly in a class body. +- Remove the deepcopy half of `_bind_attrs`. Method binding for `@command`/ + `@scan` (the `UnboundCommand`/`UnboundScan` machinery) is unaffected and + stays, since it does not require deepcopy — see decision 14 (`@attr_rw` + decorator sugar). +- Remove `HintedAttribute` and `_validate_type_hints`/`_validate_hinted_*` as + a *separate* validation-only pass. Their job — "this hinted child must + exist with the right type after initialisation" — is subsumed into the new + `ControllerFiller`. +- Introduce `ControllerFiller`, a direct structural port of ophyd-async's + `DeviceFiller`. It scans class-body type hints (`AttrR/W/RW[T]`, + `Command[P, T]` — see [ADR 15](0015-typed-commands.md), nested `Controller` + / `ControllerVector[T]`), creates children **unfilled**, and tracks + filled/unfilled state per child. `check_filled(source)` raises, listing by + name, anything a `Controller`'s `initialise()` promised via a hint but did + not provision. +- `ControllerFiller` yields `(child, extras)` for each created child — the + `extras` being anything else found in an `Annotated[...]` hint — so that + protocol libraries (a future SCPI package, for example) can define their + own extras vocabulary the same way ophyd-async's `PvSuffix`/`TangoPolling` + do. Core FastCS defines **no** extras vocabulary for 1.0 (decision 3 of + #388). +- The refined rule from decision 14 of #388: *class body = declarations + + decorated behaviour; instance scope = construction with data.* This keeps + `@command`/`@scan`, and the new `@attr_r`/`@attr_rw` sugar, as class-body + citizens, since none of them require per-instance deepcopy — they bind a + method to `self` at construction time instead. + +Example, before and after: + +```python +# Before: class-scope instance, deepcopy'd per-instance +class TemperatureRampController(Controller): + start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) + +# After: bare hint, filled procedurally +class TemperatureRampController(Controller): + start: AttrRW[int] + + def __init__(self, index: int, conn: IPConnection) -> None: + super().__init__() + suffix = f"{index:02d}" + self.start = AttrRW(Int(), io=TempIO(conn, "S", suffix)) +``` + +Introspecting controllers (`fastcs-eiger`, `fastcs-secop`, +`fastcs-PandABlocks`, `fastcs-catio`'s dynamic path) keep working exactly as +today's `initialise()` + `add_attribute` pattern, but the fully-dynamic case +where the *set* of attributes is not known until a network round-trip +completes (`fastcs-PandABlocks`, `fastcs-secop`) needs `ControllerFiller` to +support filling children that were never hinted at all — mirroring +`DeviceFiller.fill_child_signal`'s "no annotation existed, introspection +added an undeclared attribute" path. This is a harder requirement than most +of ophyd-async's own connectors exercise (PVI and Tango both fill *some* +undeclared children, but FastCS's dynamic drivers may have **zero** static +hints and still need to build a full attribute tree from nothing) and is +called out below as an open question. + +## Consequences + +- Every existing driver using class-scope `Attribute` instances needs + migration to bare hints + `__init__`/`initialise()` construction — see the + Example 1 (`DRAFT: Example 1 — IORef temperature controller`) and Example + 2 (`DRAFT: Example 2 — introspectable Eiger-style controller`) sub-issues + of #388, and the corresponding downstream repo work. +- `Controller.__init__` no longer needs to run `_bind_attrs`, simplifying + construction and removing a source of deepcopy-related bugs. +- Static typing improves: a bare hint `frames: AttrRW[int]` is exactly the + type a type checker sees, with no deepcopy step that could plausibly + change it. +- `ControllerFiller` becomes a new stable, documented surface — see + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) for how + it interacts with the stable `ControllerAPI` surface consumed by the + embedded ophyd-async connector. + +## Open questions + +1. Does `ControllerFiller` need to support "no hints exist at all — build the + entire attribute tree from introspected data" (the `fastcs-PandABlocks` + and `fastcs-secop` case), or is some minimal static shape (even just a + marker on the `Controller` subclass) always required? `DeviceFiller` has + no precedent for the fully-hint-free case. +2. `fastcs-catio`'s dynamic path builds whole controller *classes* at runtime + via `type(...)` from YAML definitions, before any instance (and hence any + `ControllerFiller`) exists. Is that pattern still supported, unsupported, + or does it need to move to instance-level dynamic attribute construction + under the new model? +3. `fastcs-eiger`'s `OdinController.initialise()` constructs new attributes + that reference sibling sub-controllers' attributes, assuming those + sub-controllers already exist. Does `ControllerFiller` impose an + ordering/dependency mechanism between sibling children, or is this left + as an `initialise()` implementation detail (call `super().initialise()` + first)? +4. Should `check_filled` be able to distinguish "this hinted child is + optional" (ophyd-async's `Optional[X]` convention), or does FastCS treat + every hint as required for 1.0? +5. Exact `ControllerFiller` method names/signatures are left to the + prototype — should they mirror `DeviceFiller`'s names 1:1 + (`fill_child_signal` → `fill_child_attribute`?) for discoverability by + developers who know both libraries, or diverge where FastCS's vocabulary + (`Attribute` vs `Signal`) differs? From 710433cdbd3f3ad75ef101c106684a50fa818d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:20:22 +0000 Subject: [PATCH 02/36] docs: ADR 14 - AttributeIO R/W/RW rework, remove AttributeIORef --- .../decisions/0014-attribute-io-rw-rework.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/explanations/decisions/0014-attribute-io-rw-rework.md diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md new file mode 100644 index 000000000..7d731a366 --- /dev/null +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -0,0 +1,186 @@ +# 14. AttributeIO R/W/RW Rework and Removal of AttributeIORef + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 9](0009-handler-to-attribute-io-pattern.md), [ADR 12](0012-attribute-io-naming-convention.md) + +## Status + +Proposed + +## Context + +[ADR 9](0009-handler-to-attribute-io-pattern.md) split the old `Handler` +pattern into `AttributeIO` (behaviour, one instance per `Controller`, +shared across attributes) and `AttributeIORef` (per-attribute resource +specification, dispatched to the right `AttributeIO` by type at +`_connect_attribute_ios` time). Its sole structural justification was that +class-scope `Attribute` instances are created before `__init__` runs, so +they cannot close over a live connection — the `AttributeIORef` only needed +to carry inert data (a register name, a URI) until the matching +`AttributeIO` was found by type at `post_initialise()`. + +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) removes +class-scope `Attribute` instances entirely. Every attribute is now +constructed procedurally, in `__init__` or `initialise()`, where a live +connection is already in scope. The situation `AttributeIORef` was invented +to solve no longer exists. + +We also verified downstream that nothing outside FastCS consumes `io_ref` or +the IO registry directly — every consumer bottoms out in +`attr.set_on_put_callback(io.send)` / `attr.set_update_callback(io.update)` +(`base_controller.py:_connect_attribute_ios`), i.e. the ref/registry split is +a dispatch layer over callbacks that already exist as plain methods. + +The dispatch-by-type registry has real costs in our downstream drivers: + +- `fastcs-catio` registers **three** separate `AttributeIO`/`AttributeIORef` + pairs on one `Controller` (`ios=[poll_io, symbol_io, coe_io]`) purely so + each attribute's `io_ref` type can select the right one at + `_connect_attribute_ios` time — indirection that a direct `io=` argument + removes outright. +- `fastcs-secop` needs a private escape hatch, + `attr._call_sync_setpoint_callbacks`, to push setpoint echoes from its + `send()` implementation, because the current `AttributeIO.send` signature + has no sanctioned way to do this — flagged in-code as pending a public API. +- `fastcs-PandABlocks`'s `UnitsIO.send` mutates a *sibling* attribute's + datatype (`attribute_to_scale.update_datatype(...)`), reaching outside the + attribute it was invoked for — a pattern the new IO shape should not make + harder, even though it stays an edge case. + +## Decision + +Replace `AttributeIO`/`AttributeIORef` with three focused, per-attribute IO +base classes with abstract `update`/`send` methods, passed as a single `io=` +constructor argument: + +```python +class ReadIO(Generic[DType_T], ABC): + def __init__(self, update_period: float | None = None): ... + + @abstractmethod + async def update(self, attr: AttrR[DType_T]) -> None: ... + + +class WriteIO(Generic[DType_T], ABC): + @abstractmethod + async def send(self, attr: AttrW[DType_T], value: DType_T) -> None: ... + + +class ReadWriteIO(ReadIO[DType_T], WriteIO[DType_T], ABC): ... +``` + +(Working names per #388; exact naming — `ReadIO`/`WriteIO`/`ReadWriteIO` vs. +`AttrRIO`/`AttrWIO`/`AttrRWIO` — is an open question below and in +[ADR 17](0017-naming-pass.md).) + +- `AttrR(dt, io: ReadIO[DType_T] | None)`, `AttrW(dt, io: WriteIO[DType_T] | + None)`, `AttrRW(dt, io: ReadWriteIO[DType_T] | None)`. Passing a + read-only IO to an `AttrRW` is a **static** type error, not a runtime + `_validate_io` check — the abstract methods force a subclass to implement + the right surface for the `Attr` flavour it is attached to. +- `update_period` moves onto `ReadIO` — it describes the IO's polling + behaviour, not a property of the attribute. `Controller.create_api_and_tasks` + schedules from `attr.io.update_period` instead of pattern-matching on + `AttributeIORef` (`control_system.py`/`controller.py`'s + `case AttrR(_io_ref=AttributeIORef(update_period=update_period))` becomes a + direct attribute access). +- **Delete:** `AttributeIORef`, the `ios=` constructor kwarg on + `BaseController`/`Controller`/`ControllerVector`, `_validate_io`, + `_connect_attribute_ios`, `_attribute_ref_io_map`, `__init_subclass__`'s + generic-arg sniffing in `AttributeIO`, and the second TypeVar — + `Attribute[DType_T, AttributeIORefT]` collapses to `Attribute[DType_T]`, + making `AttrRW[float]` structurally isomorphic to ophyd-async's + `SignalRW[float]`. +- `io=None` keeps today's soft-attribute behaviour: `AttrRW` self-wires + setpoint→readback via `_internal_update`, and the sync-setpoint machinery + is unaffected. This remains the analogue of ophyd-async's + `soft_signal_rw`. +- A concrete `CallbackReadIO`/`CallbackWriteIO` pair ships in core as an + escape hatch for one-off attributes, mirroring `soft_command` — e.g. + `CallbackReadIO(update=cb, update_period=0.2)` — without requiring a full + subclass. + +Migration is mechanical for the common case (an old `AttributeIO` subclass +absorbs its `AttributeIORef`'s fields into its own `__init__` and is +constructed once per attribute instead of once per controller): + +```python +# Before (ADR 9 shape) +class TempIORef(AttributeIORef): + name: str + +class TempIO(AttributeIO[float, TempIORef]): + async def update(self, attr: AttrR[float, TempIORef]) -> None: + resp = await self._conn.send_query(f"{attr.io_ref.name}?\r\n") + await attr.update(float(resp)) + +ramp_rate = AttrRW(Float(), io_ref=TempIORef(name="R")) +# ... elsewhere: Controller(ios=[TempIO(conn)]) + +# After +class TempIO(ReadWriteIO[float]): + def __init__(self, conn: IPConnection, name: str, update_period=0.2): + super().__init__(update_period=update_period) + self._conn, self._name = conn, name + + async def update(self, attr: AttrR[float]) -> None: + resp = await self._conn.send_query(f"{self._name}?\r\n") + await attr.update(float(resp)) + + async def send(self, attr: AttrW[float], value: float) -> None: + await self._conn.send_command(f"{self._name}={value}\r\n") + +self.ramp_rate = AttrRW(Float(), io=TempIO(conn, "R")) +``` + +`fastcs-catio`'s three-IO-per-controller pattern becomes three IO +*instances*, one per relevant attribute, with no registry needed at all. +`fastcs-secop`'s private `_call_sync_setpoint_callbacks` call is replaced by +a public method on `AttrW`/`ReadWriteIO` — exact shape is an open question. + +## Consequences + +- Every driver that declared `AttributeIORef` subclasses must migrate them + into `AttributeIO.__init__` fields — see the affected §9 files in the + sub-issues of #388 (`attributes/`, `controllers/base_controller.py`, + `controllers/controller.py`) and the corresponding downstream repo issues. +- `Attribute` loses its second generic parameter, simplifying every type + hint in downstream code (`AttrR[float, MyRef]` → `AttrR[float]`). +- Access-mode compatibility between an `Attr` and its `io=` argument is + caught by the type checker instead of at runtime in `_validate_io` — + earlier feedback for driver authors, at the cost of losing the runtime + "no AttributeIO registered for this ref type" error message; a + misconfigured `io=None` on an attribute that needed IO now simply behaves + as a soft attribute rather than raising loudly. Whether this needs a + runtime check as well (e.g. in `post_initialise`) is an open question. +- [ADR 12](0012-attribute-io-naming-convention.md)'s guidance (subclass to + get a shorter driver-local name) still applies to the new `ReadIO`/ + `WriteIO`/`ReadWriteIO` names. + +## Open questions + +1. Final class names: `ReadIO`/`WriteIO`/`ReadWriteIO` vs. `AttrRIO`/ + `AttrWIO`/`AttrRWIO` (mirroring the `Attr` family) vs. something else + entirely — see [ADR 17](0017-naming-pass.md). +2. What is the public replacement for `fastcs-secop`'s + `_call_sync_setpoint_callbacks` workaround? Does `WriteIO.send` get an + optional `sync_setpoint` callback argument, or does `AttrW.put` grow a + public method IO authors can call from `send`? +3. Should there be a runtime check (e.g. at `post_initialise`) that + catches "read-only IO passed to a write-capable `Attr`" for cases the + static type checker cannot see (e.g. an `Any`-typed IO built + dynamically, as in `fastcs-secop`'s and `fastcs-PandABlocks`'s + introspection-driven construction)? Both of those drivers build + attributes and their IO from runtime data where static checking cannot + help. +4. `fastcs-PandABlocks`'s `UnitsIO.send` mutates a sibling attribute's + datatype and `fastcs-catio` recovers per-attribute metadata via + `attribute.io_ref` from *outside* the attribute's own `send`/`update` + (`panda_controller.py:_coerce_value_to_panda_type`). With `io_ref` + removed, what is the sanctioned way to recover an attribute's IO-specific + metadata (e.g. `attr.io` becoming a public, typed property)? +5. Do we ship `CallbackReadIO`/`CallbackWriteIO` in `fastcs` core for 1.0, or + leave the "no subclass needed" one-off case entirely to driver authors + using `io=None` plus manual `set_update_callback`? From 3ce16b99ee6160de47f47ce1cdd5a846825bc7d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:20:55 +0000 Subject: [PATCH 03/36] docs: ADR 15 - typed commands --- .../decisions/0015-typed-commands.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/explanations/decisions/0015-typed-commands.md diff --git a/docs/explanations/decisions/0015-typed-commands.md b/docs/explanations/decisions/0015-typed-commands.md new file mode 100644 index 000000000..a3d6c9b37 --- /dev/null +++ b/docs/explanations/decisions/0015-typed-commands.md @@ -0,0 +1,102 @@ +# 15. Typed Commands + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +FastCS `Command` (`src/fastcs/methods/command.py`) is void/void only: +`Method._validate` requires zero parameters and `None`/empty return type. +`UnboundCommand.bind` produces a `Command` wrapping a zero-arg, no-return +async callable. `Method.__init__` already captures the full +`inspect.Signature` of the wrapped function (`method.py:21`), but `Command`'s +own `_validate` throws that signature away by rejecting anything with +parameters. + +ophyd-async's equivalent, `Command[P, T]` (`ophyd_async/core/_command.py`), +carries a real parameter and return type, exposed via `CommandBackend.signature` +and `CommandBackend.execute(*args, **kwargs) -> T`. `TriggerableCommand = +Command[[], None]` is the void/void case, expressed as a special case of the +general one rather than the only case. + +This gap already shows in our downstream drivers rather than being +speculative: `fastcs-secop` builds command arguments and results dynamically +from SECoP's wire `datainfo` (`_controllers.py:102-110`) — a genuinely typed +(if dynamically-typed) command surface that FastCS's void/void `Command` +cannot represent today, forcing `fastcs-secop` to route command arguments +through attributes on a dedicated `SecopCommandController` instead of a +single typed call. + +## Decision + +Lift the zero-arg/no-return restriction in `Method`/`Command._validate`, and +introduce `Command[P, T]` generic over parameters and return type, keeping +the already-captured `inspect.Signature` as the public surface — +`ControllerAPI` exposes it directly, mirroring `CommandBackend.signature`. + +Transport capability is declared, not assumed uniform: + +- **Tango, REST, GraphQL, and the embedded ophyd-async connector** serve + typed commands fully — arguments and return value round-trip through + each protocol's native typed-call mechanism. +- **EPICS CA/PVA** stay void/void at the wire level (there is no PV + representation of "call with these typed arguments, get this typed + return" that doesn't already exist as separate attributes). They **skip + typed commands with a warning** at start-up rather than failing to serve + the controller at all. A command the user explicitly declares as typed + *and* forces to be served over an EPICS-only transport is a hard error — + matching the existing ophyd-async connector's behaviour, which errors + rather than silently drops when a `Device` requires a capability its + connector cannot provide. + +```python +class Ramp(Controller): + move_to: Command[[float], None] # typed: not served over CA/PVA + stop: Command[[], None] # void/void: served everywhere +``` + +## Consequences + +- `Command.__call__` gains real `*args`/`**kwargs` forwarding instead of a + bare `await self.fn()`; `UnboundCommand.bind` needs the same treatment. +- `ControllerAPI` (or its per-command entries) needs to expose the + signature to transports, so each transport can decide serve-fully / + serve-with-warning / hard-error per decision above. +- EPICS transports (`transports/epics/ca`, `transports/epics/pva`) need a + capability check at controller-API-build time, producing a startup-time + warning log rather than a runtime failure per typed-command call. +- `fastcs-secop`'s dynamically-typed command args/results likely still need + `Command[Any, Any]` or a per-instance generated type, since SECoP's + `datainfo` is only known at connect time — full static typing of command + signatures is not achievable for introspection-driven drivers, only for + statically-declared ones. This mirrors the same "hint vs. no-hint" tension + as [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). +- Command args/return values need datatype validation analogous to + `Attribute`'s `DataType.validate` — whether they reuse the `DataType` + family directly or a separate mechanism is an open question. + +## Open questions + +1. Do command arguments/return values validate through the same `DataType` + family attributes use, or is a separate (lighter-weight, since there's no + "current value" to cache) validation path introduced? +2. For `fastcs-secop`-style dynamically-typed commands, what's the + recommended pattern — `Command[Any, Any]` with manual validation inside + the handler, or a documented way to construct a `Command[P, T]` with `P`/ + `T` determined at runtime (which conflicts with normal generic typing)? +3. Exactly what should the EPICS skip-with-warning message say, and where — + at controller construction, at `post_initialise`, or lazily the first + time a typed command is looked up by the transport? +4. Should typed commands support partial typing (e.g. typed arguments but + void return, or vice versa), or is it all-or-nothing relative to + `Command[[], None]`? +5. Does the REST/GraphQL/Tango serialisation of complex argument/return + types (numpy arrays, `Enum`, `Table`) reuse existing `DataType` + serialisation code from attributes, and if so does that argue for + sharing more machinery between `Attribute` and `Command` than they do + today? From 6d6a1919cc33d1bc0ef75b6ad1de867af922befb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:21:33 +0000 Subject: [PATCH 04/36] docs: ADR 16 - setpoint cache, native timestamps, ControllerRunner --- ...-cache-timestamps-and-controller-runner.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md diff --git a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md new file mode 100644 index 000000000..3c20a85bd --- /dev/null +++ b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md @@ -0,0 +1,112 @@ +# 16. AttrW Setpoint Cache, Native Timestamps, and ControllerRunner + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +Three related gaps block a clean embedded ophyd-async connector +(see [ADR 19](0019-embedded-ophyd-async-connector.md)) and are useful to all +transports independently of embedding: + +1. **No cached setpoint.** `AttrW.put` (`src/fastcs/attributes/attr_w.py`) + applies a setpoint via `_on_put_callback` but does not retain it anywhere + queryable. ophyd-async's `SignalBackend.get_setpoint()` — needed for + `locate()` — has no FastCS equivalent to read from. +2. **No FastCS-native timestamps.** `AttrR.update` (`attr_r.py`) stamps + nothing; individual transports each do their own thing (EPICS records + get a timestamp from the record subsystem, Tango pushes are unstamped). + An embedded connector currently has no choice but to stamp receive-time + only, which is a real information loss versus what the underlying device + protocol may already provide (Tango event timestamps, EPICS record + timestamps at source). +3. **No documented, extracted runtime.** `FastCS.serve` (`control_system.py`) + inlines the full controller lifecycle — `initialise()` → + `post_initialise()` → `create_api_and_tasks()` → `connect()` → initial + coroutines → scan tasks — as private logic inside the `serve` coroutine. + There is no standalone object an embedding connector can start/stop + without also pulling in `FastCS`'s transport-serving and interactive-shell + concerns. + +Decision 13 of #388 requires this lifecycle, plus `ControllerAPI` and the +attribute/command runtime methods, to be formalised as fastcs-core's single +documented "stable interface" that the ophyd-async connector is restricted +to using — no reaching into `BaseController` internals. + +## Decision + +**Setpoint cache:** `AttrW` gains an internally-tracked last-applied +setpoint, exposed via a public getter (name TBD — see open questions), +updated whenever `put` is called, independent of whether the underlying +`send` succeeds. This is available to all transports (not just the embedded +connector) as a "what did we last ask for" query distinct from `AttrR.get()` +("what did we last read back"). + +**Native timestamps (+ severity):** `AttrR.update` accepts an optional +timestamp (and, where meaningful, severity) alongside the value, defaulting +to current time if not supplied by the caller. This is FastCS-native, not +EPICS-specific — Tango event pushes and other IO can supply a device-side +timestamp through the same path a `ReadIO.update` call already uses. The +embedded connector stamps receive-time only as an interim measure until this +lands, per decision 10 of #388 — this is 1.0 scope, not a follow-up. + +**ControllerRunner:** Extract the controller lifecycle currently inlined in +`FastCS.serve` into a standalone `ControllerRunner` (or equivalent +`Controller.serve()`/`Controller.stop()` API), independent of the +transport-serving and interactive-shell logic that stays in `FastCS`/ +`control_system.py`. `FastCS.serve` becomes a thin caller of +`ControllerRunner` plus transport wiring. The runner owns: + +- Running `initialise()`/`post_initialise()`/`create_api_and_tasks()` once. +- Running `connect()` and the initial coroutines. +- Starting/stopping the periodic scan tasks. +- Being **idempotent** — safe to call start again after a stop, since the + embedded connector's `connect_real` may run more than once across + reconnects (see [ADR 19](0019-embedded-ophyd-async-connector.md)). + +This, together with `ControllerAPI` and the attribute/command runtime +methods (`AttrR.get`/`add_on_update_callback`, `AttrW.put` + cached +setpoint, `Attribute.datatype`/`access_mode`/`description`/`group`), becomes +the documented stable surface referenced by decision 13 of #388. + +## Consequences + +- `FastCS.serve` shrinks to transport orchestration; the controller + lifecycle it currently inlines becomes independently testable and + reusable without instantiating a `FastCS` object or any `Transport`. +- Every `ReadIO.update` implementation *may* supply a timestamp/severity, + but existing IO that does not is unaffected — defaults to current time, + severity unset. +- Transports gain access to a real setpoint distinct from the readback + value; whether EPICS/Tango/REST/GraphQL surface this as new fields is + transport-specific follow-up work, not part of this ADR. +- The embedded ophyd-async connector becomes buildable against a documented, + narrow surface instead of `BaseController` internals — see + [ADR 19](0019-embedded-ophyd-async-connector.md). + +## Open questions + +1. Setpoint cache accessor name and shape — `AttrW.setpoint` property, + `AttrW.get_setpoint()` method (mirroring `SignalBackend.get_setpoint()`), + or folded into `AttrW.put`'s return value? +2. Timestamp/severity type — reuse a existing convention (e.g. + `ophyd_async`/bluesky's `Reading`/event-model shape) or define a + FastCS-native pair? Decision 12 of #388 already aligns numeric limits + naming with event-model `Limits` — should timestamps/severity follow the + same alignment for consistency? +3. Severity: what are the FastCS-native severity levels, and do they map + 1:1 to EPICS alarm severities, or is EPICS's severity model transport- + specific with FastCS defining its own smaller/different vocabulary? +4. Exact `ControllerRunner` API shape — a class with `start()`/`stop()`, or + `async` context-manager semantics (`async with runner:`)? The embedded + connector needs idempotent start across reconnects; does the chosen shape + make idempotency the caller's responsibility or the runner's? +5. Does `ControllerRunner` own reconnect logic (calling `Controller.reconnect()` + on scan-task failure, as `Controller._create_periodic_scan_coro` does + today), or does that stay controller-specific and out of the runner's + documented surface? From b8707b04f2255142258ee52fc3add64bb7f2a104 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:22:14 +0000 Subject: [PATCH 05/36] docs: ADR 17 - naming pass (precision, Limits, Array1D/Table hints) --- .../decisions/0017-naming-pass.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/explanations/decisions/0017-naming-pass.md diff --git a/docs/explanations/decisions/0017-naming-pass.md b/docs/explanations/decisions/0017-naming-pass.md new file mode 100644 index 000000000..f0b8989e4 --- /dev/null +++ b/docs/explanations/decisions/0017-naming-pass.md @@ -0,0 +1,105 @@ +# 17. Naming Pass: precision, Limits Alignment, Array1D/Table Hints + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +FastCS and ophyd-async independently arrived at similar concepts with +different names, which is exactly the "false friend" risk #388 opens with: +a developer moving between the two projects can be misled into assuming a +name means the same thing, or reach for a name that does not exist. + +Concretely, `_Numeric` (`src/fastcs/datatypes/_numeric.py`) and `Float` +(`src/fastcs/datatypes/float.py`) use `prec`/`min`/`max`/`min_alarm`/ +`max_alarm`; ophyd-async and the wider bluesky event-model use +`precision` and a `Limits` structure (`Limits(low, high)` per category, e.g. +control/display/alarm/warning) rather than five flat fields. FastCS's +`Waveform(array_dtype, shape)` and `Table` datatypes have no hint-level +spelling analogous to ophyd-async's `Array1D[np.int32]` (a `numpy.ndarray` +subscripted for shape) and `Table` (pydantic-based) hint syntax — under +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), bare +hints are now load-bearing (they are what `ControllerFiller` scans), so +having ophyd-async-compatible hint spellings for array/table attributes +becomes more valuable than it was when hints were validation-only. + +Since this is a pre-1.0 breaking-change window (per #388's framing — +"while breaking pre-1.0"), this is the point to make these renames, not +after 1.0 when they become a deprecation cycle. + +## Decision + +1. **`prec` → `precision`.** Rename across `_Numeric`/`Float`/wherever + `prec` appears (transports, docs, snippets). No behaviour change. +2. **Limits alignment.** Align `min`/`max`/`min_alarm`/`max_alarm` naming + with event-model `Limits` naming. This ADR records the *intent* + (converge with bluesky event-model naming so alarm/control/display limits + read the same way in FastCS and ophyd-async docs); the exact target + shape (keep four flat fields renamed, or restructure into a `Limits`-like + object) is an open question for the prototype, since it interacts with + how `DataType.validate` currently accesses these fields directly as + dataclass attributes. +3. **`Array1D`/`Table` hint spellings.** Adopt `Array1D[np.int32]` and + `Table` as the FastCS *hint* spellings a `ControllerFiller`-scanned class + body uses, mapping internally to the existing `Waveform`/table `DataType` + runtime objects (constructed the same way as today via + `AttrRW(Waveform(np.int32, shape=(4,)), io=...)` in procedural code) — + the hint is sugar for `ControllerFiller`'s type-hint scan, not a + replacement for the runtime `DataType` classes, matching decision 7 of + #388 (`DataType` classes stay as the procedural/runtime value; hints are + what `ControllerFiller` reads). + +This is explicitly the smallest naming-pass scope agreed in #388 for 1.0. A +`Prec`/`Units`/`Shape` `Annotated` extras vocabulary (letting a hint carry +precision/units/shape without a full `DataType` instance) is called out in +#388 as a **post-1.0** option enabled by, but not required by, the +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) extras +mechanism — not part of this ADR. + +## Consequences + +- Every driver using `Float(prec=...)`, `.min`/`.max`/`.min_alarm`/ + `.max_alarm` needs a mechanical rename. This is a wide, shallow diff + across all downstream repos (`fastcs-eiger`, `fastcs-catio`, + `fastcs-secop`, `fastcs-PandABlocks` all use `Float`/numeric limits + somewhere) but not a structural one, unless the Limits restructuring + (open question 2) turns out to be more than a rename. +- Transports serving `precision`/limits metadata (EPICS record fields, + Tango attribute properties, REST/GraphQL schema) need their field-name + mapping updated to read from the renamed dataclass fields. +- `Array1D`/`Table` hint spellings only affect declarative (hinted) + attribute declarations; procedural construction with `Waveform(...)`/ + `Table(...)` DataType instances is unchanged. + +## Open questions + +1. Does the Limits alignment keep four flat fields (just renamed to match + event-model terms) or restructure into an actual `Limits`-like nested + object? The latter is a bigger, more disruptive change to + `DataType.validate` and every downstream driver constructing `Float(...)` + with keyword limits. +2. Which event-model `Limits` categories does FastCS need — + control/display/alarm/warning all four, or a subset? EPICS records only + naturally distinguish alarm vs. display/control limits; does the mapping + from four FastCS fields to N event-model categories lose or need to + invent information for some transports? +3. Is `precision` an `int` (decimal places, as `prec` is today) or does + aligning with event-model conventions change its meaning/type too? +4. For `Array1D`/`Table` hints: is `Array1D[np.int32]` a real usable type at + both class-definition time (for `ControllerFiller` to scan) and at + type-checking time (for pyright), or a `TypeAlias`/`Annotated` wrapper + around `Waveform`? What does the two-way mapping (hint → `ControllerFiller` + constructs a `Waveform`; introspection-provisioned `Waveform` → does the + hint still validate it, per decision in + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) + open question 1) look like precisely? +5. Should this rename land in the same PR as + [ADR 14](0014-attribute-io-rw-rework.md) (since both touch `DataType`- + adjacent code and every downstream driver already has to touch these + files), or stay a separate, later PR per §8 work-plan ordering (item 5, + after items 1-4)? From cd10e12d815bfdcd1c71d43ea0c77877350f0e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:22:52 +0000 Subject: [PATCH 06/36] docs: ADR 18 - @attr_r/@attr_rw decorator sugar --- .../decisions/0018-attr-decorator-sugar.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/explanations/decisions/0018-attr-decorator-sugar.md diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md new file mode 100644 index 000000000..0a79f8f25 --- /dev/null +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -0,0 +1,131 @@ +# 18. Attr-from-Method Decorator Sugar (@attr_r / @attr_rw) + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +#388 §7.5 makes the case that FastCS must also sell as a way to write Tango +Device Servers, competing directly with PyTango on the trivial case, not +just on the advanced multi-transport pitch. PyTango's hello-world is one +decorated getter: + +```python +@attribute +def current(self) -> float: + return 2.5 +``` + +Under the [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +harsh declarative/procedural split (bare hints only in the class body; all +IO wiring procedural), the equivalent trivial case regresses to a method +plus a `CallbackReadIO` adapter plus explicit `__init__` wiring — strictly +more ceremony than PyTango for the simple case that most new users hit +first. This is exactly the kind of "false friend" gap #388 warns about: a +PyTango user evaluating FastCS should not find the *simple* case harder than +what they're moving away from. + +FastCS already has precedent for binding class-body decorated methods to +per-instance callables without any deepcopy hazard: `@command`/`@scan` +(`src/fastcs/methods/command.py`, `scan.py`) use `UnboundCommand`/ +`UnboundScan`, which wrap an unbound function and `.bind(controller)` a +fresh `Command`/`Scan` object per instance at `_bind_attrs` time. Because +these are fresh objects constructed per-instance (not deepcopied +prototypes), they carry none of the aliasing hazard that class-scope +`Attribute` *instances* had — which is why [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +removes the latter but keeps `@command`/`@scan`. + +## Decision + +Add `@attr_r`/`@attr_rw` (and, for symmetry, whatever `@attr_w`-only case +makes sense) as pure sugar over `AttrR`/`AttrW`/`AttrRW` plus a generated +callback-based `io=`, built on the same `Unbound*`-style bind machinery as +`@command`/`@scan` — fresh objects per instance, no prototype/deepcopy +hazard, consistent with keeping this a class-body citizen under +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md). + +```python +class PowerSupply(Controller): + @attr_rw(units="V", update_period=0.5) # dtype inferred from -> float + async def voltage(self) -> float: + return await self._conn.query("V?") + + @voltage.send + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") +``` + +- The datatype is inferred from the return type annotation of the getter + (`-> float` → `Float()`), matching how `DataType` mapping already works + elsewhere (`numpy_to_fastcs_datatype`), rather than requiring a `dtype=` + keyword the way PyTango does — decision 14 of #388 explicitly calls this + out as *better* than PyTango's `dtype=` kwarg since it's one real + annotation, checked statically. +- `@attr_r`/`@attr_rw` decorator keyword arguments (`units`, `update_period`, + etc.) map onto the equivalent `DataType`/`ReadIO`/`WriteIO` constructor + arguments from [ADR 14](0014-attribute-io-rw-rework.md) — this is sugar + over that mechanism, not a parallel one. +- `@attr_rw`'s `.send` decorator mirrors the `@voltage.send` pattern shown + above (property-style, matching `@property`/`@x.setter`), giving the + read+write pair a single logical name (`voltage`) with two decorated + methods. +- This degrades gracefully into the full `io=` object form for protocol + families with more complex needs, and into + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s + filler for introspection-driven attributes — `@attr_rw` is explicitly the + *simple* case, not a replacement for either. +- Refines the class-body rule stated in + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) to: + *class body = declarations + decorated behaviour; instance scope = + construction with data* — already true today via `@command`/`@scan`, now + extended to attributes. +- Docs gain a "FastCS for PyTango users" page pairing this decorator with + the equivalent PyTango snippet, landing alongside this PR per #388 §8 + item 5b. + +## Consequences + +- New driver code for the common "one attribute, one device call" case gets + noticeably shorter — closing the gap #388 §7.5 identifies against PyTango. +- The generated `io=` object needs a name/shape (an internal + `CallbackReadIO`/`CallbackWriteIO`-alike, per + [ADR 14](0014-attribute-io-rw-rework.md)'s open question 5) — this ADR's + sugar and that ADR's escape hatch should likely share the same underlying + callback-IO implementation rather than duplicating it. +- Adds a third way to declare an attribute (bare hint + filler; explicit + `AttrRW(..., io=...)`; `@attr_rw` sugar) — the docs need to be clear about + when to reach for which, so this doesn't become three equally-weighted + options with no guidance, undermining the "harsh split" clarity + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) is + trying to establish. +- Per #388 §8 item 5b, this sits on PR 1 (the `AttributeIO` rework) — + small and independent of the `ControllerFiller` work — so it can land + early and not block on [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). + +## Open questions + +1. Exact decorator names — `@attr_r`/`@attr_rw` as #388 proposes, or + something more explicit (`@readable_attribute`?) — and whether a + write-only `@attr_w` variant is worth adding for symmetry given `AttrW` + without a paired getter is a rarer shape in practice. +2. How are `min`/`max`/`precision` (post [ADR 17](0017-naming-pass.md)) + and other `DataType`-level metadata passed through the decorator's + keyword arguments — do they get their own decorator kwargs, or does the + decorator only take IO-shaped kwargs (`update_period`) and require + dropping to explicit `AttrRW(...)` construction for richer datatype + metadata? +3. Does `@attr_rw` support the `Array1D`/`Table` hint spellings from + [ADR 17](0017-naming-pass.md), or is decorator sugar scoped to scalar + datatypes only for 1.0? +4. Should `ControllerFiller` treat `@attr_rw`-decorated methods specially + (they don't need filling — they're already fully constructed at bind + time), or are they simply invisible to the filler the same way + `@command`/`@scan` are today? +5. Does the getter's docstring become the attribute's `description`, + mirroring how `Method._docstring` already captures `getdoc(fn)` for + `@command`/`@scan`? From 5e8705139bd1683caac092630b86f9680d0f9099 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:23:42 +0000 Subject: [PATCH 07/36] docs: ADR 19 - embedded ophyd-async connector --- .../0019-embedded-ophyd-async-connector.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/explanations/decisions/0019-embedded-ophyd-async-connector.md diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md new file mode 100644 index 000000000..9614421df --- /dev/null +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -0,0 +1,168 @@ +# 19. Embedded ophyd-async Connector + +Date: 2026-07-20 + +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) + +## Status + +Proposed + +## Context + +ophyd-async already bridges to FastCS over the network: +`ophyd_async.fastcs.core.fastcs_connector(uri)` is a `PviDeviceConnector` +talking PVA+PVI. #388 proposes an **in-process** embedding as well — running +a FastCS `Controller` directly inside a bluesky/ophyd-async process, with no +network hop, for cases like running a `TemperatureController` straight from +a bluesky plan. + +Researching ophyd-async's `DeviceFiller` +(`ophyd_async/core/_device_filler.py`) as the direct structural reference +for [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s +`ControllerFiller` surfaced the exact shape this connector needs to take: + +- `DeviceConnector.create_children_from_annotations` builds a `DeviceFiller` + once (memoised via `hasattr(self, "filler")`), then either fills + immediately or defers to `connect_real`. +- `connect_real` is where PVI and Tango connectors both actually introspect + and fill children — there is no ophyd-async precedent for "fill + everything at construction time" in a connect-time-introspecting + connector; embedding should follow the same connect-time pattern rather + than trying to fill eagerly. +- `SignalBackend`'s methods (`get_value`, `get_setpoint`, `set_callback`, + `put`, `get_datakey`) are the exact surface a `FastCSSignalBackend` needs + to implement in terms of FastCS's `AttrR.get`/`AttrW.put`/setpoint cache/ + native timestamps (from [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). +- `CommandBackend.execute`/`.signature` is the equivalent surface for typed + commands (from [ADR 15](0015-typed-commands.md)). + +This connector is explicitly the motivating consumer for +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 15](0015-typed-commands.md), and +[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) — it is +what forces those three to define a genuinely stable, documented surface +rather than an implicit one, since it lives in a different package +(ophyd-async) and cannot reach into FastCS internals the way FastCS's own +transports currently can. + +## Decision + +Per decision 6 of #388: no shared package. `FastCSDeviceConnector` lives +entirely on the ophyd-async side, behind an `ophyd-async[fastcs-embed]` +extra, importing only the stable FastCS surface formalised by +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +(`ControllerFiller`) and [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) +(`ControllerAPI` tree, `ControllerRunner`, and the attribute/command runtime +methods). Convergence is by convention (the two projects agreeing on shape), +not by shared code. + +```python +from ophyd_async.fastcs import embedded_fastcs_connector + +class TempStage(Device): + ramp_rate: SignalRW[float] + power: SignalR[float] + cancel_all: TriggerableCommand + ramps: DeviceVector[TempRamp] + +stage = TempStage(connector=embedded_fastcs_connector(TemperatureController(settings))) +await stage.connect() # runs controller lifecycle in-process +``` + +Mechanics, directly mirroring `PviDeviceConnector`/`TangoDeviceConnector`: + +- `create_children_from_annotations`: builds a `DeviceFiller` with + `FastCSSignalBackend`/`FastCSCommandBackend` factories, `filled=False` — + same lazy pattern as the network connectors. +- `connect_real` (top level): starts the `ControllerRunner` — `initialise()`, + `post_initialise()`, `create_api_and_tasks()`, `Controller.connect()`, + initial coroutines, scan tasks scheduled on the *running* (bluesky) event + loop — then walks the `ControllerAPI` tree filling children via the + `DeviceFiller`, `check_filled()`, `set_name()`. Idempotent across + reconnects, per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)'s + `ControllerRunner` requirement. +- `connect_mock` never touches the controller, so mock-mode ophyd-async + usage stays free (no FastCS controller/connection is instantiated at all). +- Lifecycle (decision 8 of #388): the connector owns the runner; shutdown + via an `atexit` hook plus an explicit `await connector.shutdown()`, which + cancels scan tasks and calls `Controller.disconnect()`. Upstream + `Device.disconnect()` is a follow-up (item 8 in #388 §8), not blocking. +- Embedded + transports simultaneously (decision 9 of #388, e.g. a CA GUI + running next to a bluesky plan) is explicitly out of scope for the first + cut, but the `ControllerRunner` is designed so a transport list can be + attached later without redesigning it. + +Backend mappings (from #388 §5, grounded against the researched +`DeviceFiller`/`SignalBackend` surface): + +| ophyd-async | FastCS | +|---|---| +| `SignalBackend.get_value` | `AttrR.get()` | +| `SignalBackend.set_callback` | `AttrR.add_on_update_callback(cb, always=True)`; stamped per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.put` | `AttrW.put(value)` | +| `SignalBackend.get_setpoint` | `AttrW` cached setpoint, [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.get_datakey` | `Attribute.datatype` → `SignalMetadata` (units, precision, limits, choices) + `make_datakey` | +| `CommandBackend.execute`/`.signature` | `Command.__call__` / captured `Signature`, [ADR 15](0015-typed-commands.md) | +| `SignalBackend.source` | e.g. `fastcs://.` | +| child `Device` / `DeviceVector` | sub-`Controller` / `ControllerVector` | +| (not exposed) | `@scan` methods — server-side only, not surfaced to ophyd-async | + +Datatype mapping: `Int`/`Float`/`Bool`/`String` → `int`/`float`/`bool`/`str`; +`Waveform(array_dtype, shape)`/`Array1D` hint (per +[ADR 17](0017-naming-pass.md)) → ophyd-async `Array1D[dtype]`; `Enum(cls)` → +the enum class itself. Two mismatches flagged as **prototype risk** in #388 +and carried into this ADR unresolved: + +- ophyd-async constrains enums to `EnumTypes` (`StrictEnum`/`SubsetEnum`/ + `SupersetEnum`); FastCS accepts any `enum.Enum`. +- fastcs `Table` vs. ophyd-async `Table` (pydantic-based) — mapping is + best-effort, mismatches should be flagged early rather than silently + coerced. + +## Consequences + +- The stable FastCS interface promised in + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)/ + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) gets + its first external, cross-repo consumer — any accidental internal + dependency this connector picks up is a signal that surface isn't + actually stable yet. +- This is entirely an ophyd-async-side deliverable (§8 items 6-8); + fastcs-core work is a dependency, not part of this repo's PRs. +- Per #388 coordination note: items 1-4 of the §8 work plan land in FastCS + before item 6 is mergeable; prototyping item 6 against a FastCS branch to + validate the stable interface *before* freezing it is the recommended + order, i.e. this connector should be prototyped against the `refactor` + branch here as the other ADRs' implementations land, not written blind + against a spec. +- `fastcs-demo`'s temperature controller simulation + (`fastcs.demo.simulation`) is the existing sim device ophyd-async tests + against — no new simulated device is needed for the first cut. + +## Open questions + +1. Enum conversion: does the connector require FastCS `Enum` datatypes used + with embedding to be `StrictEnum`/`SubsetEnum`/`SupersetEnum` subclasses + (pushing a constraint back onto FastCS driver authors who want embedding + support), or does it do runtime conversion/wrapping, and what happens to + values that don't fit ophyd-async's stricter model? +2. `Table` mapping: is a real bidirectional pydantic-model ↔ fastcs-`Table` + converter in scope for the first cut, or is `Table` explicitly + unsupported/best-effort-only initially, with a hard error on mismatch + rather than silent coercion? +3. Where does `@scan`-derived state that isn't exposed as a `Signal` go — + is it simply invisible to ophyd-async (server-side only, as the mapping + table states), or does some `@scan` output need a path to surface as a + `SignalR` (e.g. `fastcs-eiger`'s `update_voltages` `@scan` feeding + per-ramp `AttrR`s — those `AttrR`s are visible, but would a *pure* + `@scan`-only value ever need exposing)? +4. How does `embedded_fastcs_connector` handle a `Controller` that raises + during `initialise()` (e.g. a device that's unreachable at embed time) + — does `connect_real` propagate the exception directly to + `Device.connect()`, retry, or something else? +5. Should the embedded connector's shutdown (`atexit` + explicit + `await connector.shutdown()`) also be triggered by ophyd-async's own + `Device.disconnect()` once that upstream work lands (#388 §8 item 8), and + does that imply `ControllerRunner.stop()` needs to be safely callable + from a synchronous `atexit` context as well as an async one? From 56b03fa0ca0ce9222ca95c3134b1e5c6a6ef1127 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:26:29 +0000 Subject: [PATCH 08/36] examples: scaffold living-review-artifact package for #388 --- examples/README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++ examples/__init__.py | 4 +++ 2 files changed, 69 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/__init__.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..d769ffeef --- /dev/null +++ b/examples/README.md @@ -0,0 +1,65 @@ +# examples/ + +This package is the **living review artifact** for the FastCS / ophyd-async +API-convergence refactor tracked in +[issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) and its +[ADRs](../docs/explanations/decisions/) (0013-0019). + +It exists so that every framework PR in the refactor has something concrete +to run against, alongside the unit test suite: three example controllers, +each written in one of the three styles the refactor is converging FastCS +towards. As each framework PR lands (`AttributeIO` rework, `ControllerFiller`, +typed commands, the setpoint cache/timestamps/`ControllerRunner`, the naming +pass, `@attr_rw` sugar), the example(s) it affects are updated in the *same* +PR, so `examples/` always reflects current `main` and stays green under +`uv run --locked tox`. It is not a tutorial or documentation snippet source — +those live under `docs/`. + +## The three styles + +1. **IORef temperature controller** (`examples/example_1_ioref/`, tracked by + the `DRAFT: Example 1` sub-issue of #388). A deliberately-messy adaptation + of `src/fastcs/demo/controllers.py`, written against **whatever the + current API is** at the time it's updated. It starts on the *pre-refactor* + `AttributeIORef`/class-scope-instance API and is gradually cleaned up as + each framework PR lands — e.g. once + [ADR 14](../docs/explanations/decisions/0014-attribute-io-rw-rework.md)'s + `AttributeIO` rework merges, this example's `io_ref=`/`ios=[...]` wiring + is updated to `io=` in that same PR. This is intentional: it is the + baseline that proves each framework PR doesn't break a real (if simple) + driver, and it visibly tracks the migration path a downstream repo like + `fastcs-eiger` or `fastcs-catio` would need to follow. + +2. **Introspectable Eiger-style controller** (`examples/example_2_introspectable/`, + tracked by the `DRAFT: Example 2` sub-issue of #388). Mirrors a REST API + the way `fastcs-eiger`, `fastcs-secop`, and `fastcs-PandABlocks` do today: + attributes are built from data queried at `initialise()` time, not + declared as class-scope instances. This is the example that exercises + [ADR 13](../docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md)'s + `ControllerFiller` and bare-hint declarations once that PR lands. + +3. **PyTango-style `@attr_rw` device** (`examples/example_3_decorator/`, + tracked by the `DRAFT: Example 3` sub-issue of #388, blocked on the + `@attr_rw` decorator-sugar issue). A trivial getter/setter device in the + style of + [ADR 18](../docs/explanations/decisions/0018-attr-decorator-sugar.md), + demonstrating the simple case FastCS needs to match PyTango on. + +## Status + +**Scaffold only.** This PR (the ADR seed) adds this package, its structure, +and this README — it does not implement the framework changes or the three +example controllers themselves. Each example is implemented by its own +tracked sub-issue of #388, against the framework API as it exists once that +issue's dependencies have landed. See the `Blocked by:` lines on each +`DRAFT:` sub-issue for the landing order. + +## Keeping it green + +Every framework PR that touches `src/fastcs/` and affects one of these three +styles must update the corresponding example(s) in the same PR, and +`uv run --locked tox` must pass. This is the acceptance criterion listed on +every sub-issue of #388 for exactly this reason: it keeps the examples +honest as a description of "how do I actually write a FastCS driver today," +rather than letting them drift out of sync with the framework the way +standalone documentation snippets can. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 000000000..7cb4edb3d --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,4 @@ +"""Living review artifacts for the #388 API-convergence refactor. + +See ``examples/README.md`` for what this package is and how it's used. +""" From ce9ccc05367b3df08d45f241e646a51c8b4e7ae6 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 21 Jul 2026 10:09:34 +0000 Subject: [PATCH 09/36] docs: fold #402 review resolutions into ADRs 0013-0019 Incorporate coretl's inline review decisions: drop DataType (python types + *Meta), unify callback IO into the @attr factory, the __init__ filler rule + no-hints/Optional/external-add, nested Limits with inheritance, get_setpoint + ControllerRunner(start/stop) owning reconnect, severity enum, args/returns typed separately (kwargs -> spike #403), enum/Table handling, and dropping the Device.disconnect proposal for connect(force_reconnect=True) + atexit. Remaining deferred items (@shihab-dls, @Tom-Willemsen) kept as explicit open questions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- ...-procedural-split-and-controller-filler.md | 92 +++++++++---------- .../decisions/0014-attribute-io-rw-rework.md | 65 +++++++------ .../decisions/0015-typed-commands.md | 62 ++++++++----- ...-cache-timestamps-and-controller-runner.md | 54 +++++------ .../decisions/0017-naming-pass.md | 47 ++++------ .../decisions/0018-attr-decorator-sugar.md | 64 ++++++------- .../0019-embedded-ophyd-async-connector.md | 67 +++++++------- 7 files changed, 226 insertions(+), 225 deletions(-) diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md index 9e2578865..847de3a58 100644 --- a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -83,35 +83,44 @@ bare type hints only; instance scope = procedural construction.** Concretely: citizens, since none of them require per-instance deepcopy — they bind a method to `self` at construction time instead. -Example, before and after: +Two patterns follow, and the class body distinguishes them: -```python -# Before: class-scope instance, deepcopy'd per-instance -class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) +**Procedural, no hint** — the value is fully constructed in `__init__`, so it +needs no class-body declaration at all (the temperature controller): -# After: bare hint, filled procedurally +```python class TemperatureRampController(Controller): - start: AttrRW[int] - def __init__(self, index: int, conn: IPConnection) -> None: super().__init__() suffix = f"{index:02d}" self.start = AttrRW(Int(), io=TempIO(conn, "S", suffix)) ``` -Introspecting controllers (`fastcs-eiger`, `fastcs-secop`, -`fastcs-PandABlocks`, `fastcs-catio`'s dynamic path) keep working exactly as -today's `initialise()` + `add_attribute` pattern, but the fully-dynamic case -where the *set* of attributes is not known until a network round-trip -completes (`fastcs-PandABlocks`, `fastcs-secop`) needs `ControllerFiller` to -support filling children that were never hinted at all — mirroring -`DeviceFiller.fill_child_signal`'s "no annotation existed, introspection -added an undeclared attribute" path. This is a harder requirement than most -of ophyd-async's own connectors exercise (PVI and Tango both fill *some* -undeclared children, but FastCS's dynamic drivers may have **zero** static -hints and still need to build a full attribute tree from nothing) and is -called out below as an open question. +**Declarative hint + filler** — the value is *promised* by a hint and +provisioned by introspection at connect time (the Eiger-like case): + +```python +class OdinDetector(Controller): + frames: AttrRW[int] # must exist by the end of initialise() + + async def initialise(self) -> None: + for name, meta in await self._query_parameter_tree(): + self.filler.fill_attribute(name, ...) +``` + +**The rule** (identical to ophyd-async's rule for `Signal`s): *at the end of +`__init__`, any Attribute referenced in code — and therefore carrying a type +hint — must exist.* Only `__init__` is serial; `initialise()` may then run in +parallel across controllers. + +Introspecting controllers keep working as today's `initialise()` + +`add_attribute` pattern. The fully-dynamic case — where the *set* of +attributes is not known until a network round-trip completes +(`fastcs-PandABlocks`, `fastcs-secop`) — needs `ControllerFiller` to fill +children that were never hinted at all. This is **no harder than ophyd-async +already supports**: `Device(connector=PviConnector(prefix))` fills a whole +`Signal` tree from introspection with no hints required, and `ControllerFiller` +mirrors that `DeviceFiller` path directly. ## Consequences @@ -130,29 +139,20 @@ called out below as an open question. it interacts with the stable `ControllerAPI` surface consumed by the embedded ophyd-async connector. -## Open questions - -1. Does `ControllerFiller` need to support "no hints exist at all — build the - entire attribute tree from introspected data" (the `fastcs-PandABlocks` - and `fastcs-secop` case), or is some minimal static shape (even just a - marker on the `Controller` subclass) always required? `DeviceFiller` has - no precedent for the fully-hint-free case. -2. `fastcs-catio`'s dynamic path builds whole controller *classes* at runtime - via `type(...)` from YAML definitions, before any instance (and hence any - `ControllerFiller`) exists. Is that pattern still supported, unsupported, - or does it need to move to instance-level dynamic attribute construction - under the new model? -3. `fastcs-eiger`'s `OdinController.initialise()` constructs new attributes - that reference sibling sub-controllers' attributes, assuming those - sub-controllers already exist. Does `ControllerFiller` impose an - ordering/dependency mechanism between sibling children, or is this left - as an `initialise()` implementation detail (call `super().initialise()` - first)? -4. Should `check_filled` be able to distinguish "this hinted child is - optional" (ophyd-async's `Optional[X]` convention), or does FastCS treat - every hint as required for 1.0? -5. Exact `ControllerFiller` method names/signatures are left to the - prototype — should they mirror `DeviceFiller`'s names 1:1 - (`fill_child_signal` → `fill_child_attribute`?) for discoverability by - developers who know both libraries, or diverge where FastCS's vocabulary - (`Attribute` vs `Signal`) differs? +## Resolved in review (#402) + +1. **`ControllerFiller` must support "no hints at all"** — build the whole + attribute tree from introspected data (`fastcs-PandABlocks`, `fastcs-secop`). +2. **`fastcs-catio`'s runtime `type(...)` class-building is *not* supported.** + A bare `Controller` instead allows attributes to be added onto it from the + outside — which is exactly what the fillers do — so catio moves to + instance-level dynamic attribute construction. +3. **No sibling-ordering mechanism.** The rule "any hint-referenced Attribute + must exist by the end of `__init__`" makes `initialise()` parallelisable; + sibling dependencies are an `initialise()` implementation detail (call + `super().initialise()` first). +4. **`Optional[X]` hints are supported** — `check_filled` treats an optional + hint as not-required. +5. **Follow `DeviceFiller`'s structure, not its names.** Architectural + similarity matters; method names match only where FastCS's vocabulary + (`Attribute` vs `Signal`) makes them fit. diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 7d731a366..fc9e2ed0f 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -73,7 +73,9 @@ class ReadWriteIO(ReadIO[DType_T], WriteIO[DType_T], ABC): ... (Working names per #388; exact naming — `ReadIO`/`WriteIO`/`ReadWriteIO` vs. `AttrRIO`/`AttrWIO`/`AttrRWIO` — is an open question below and in -[ADR 17](0017-naming-pass.md).) +[ADR 17](0017-naming-pass.md).) Concrete IO classes are **dataclasses**, so +per-attribute fields (register name, command string, `update_period`) are +declared without a boilerplate `__init__`. - `AttrR(dt, io: ReadIO[DType_T] | None)`, `AttrW(dt, io: WriteIO[DType_T] | None)`, `AttrRW(dt, io: ReadWriteIO[DType_T] | None)`. Passing a @@ -97,10 +99,16 @@ class ReadWriteIO(ReadIO[DType_T], WriteIO[DType_T], ABC): ... setpoint→readback via `_internal_update`, and the sync-setpoint machinery is unaffected. This remains the analogue of ophyd-async's `soft_signal_rw`. -- A concrete `CallbackReadIO`/`CallbackWriteIO` pair ships in core as an - escape hatch for one-off attributes, mirroring `soft_command` — e.g. - `CallbackReadIO(update=cb, update_period=0.2)` — without requiring a full - subclass. +- The one-off "no subclass needed" case is served by the unified `attr` + factory (see [ADR 18](0018-attr-decorator-sugar.md)) — `self.x = + attr(getter=cb)` / `@attr` — **not** by separate `CallbackReadIO`/ + `CallbackWriteIO` classes. The same `attr` covers the read-only and + read/write callback cases, so the two adapters are not shipped. + +> Datatype spelling: the `Float()` etc. in the examples below predate +> [ADR 17](0017-naming-pass.md); read them as the ADR-17 python-type + `*Meta` +> form (`AttrRW(float, precision=3, io=...)`), since the `DataType` family is +> removed. Migration is mechanical for the common case (an old `AttributeIO` subclass absorbs its `AttributeIORef`'s fields into its own `__init__` and is @@ -159,28 +167,25 @@ a public method on `AttrW`/`ReadWriteIO` — exact shape is an open question. get a shorter driver-local name) still applies to the new `ReadIO`/ `WriteIO`/`ReadWriteIO` names. -## Open questions - -1. Final class names: `ReadIO`/`WriteIO`/`ReadWriteIO` vs. `AttrRIO`/ - `AttrWIO`/`AttrRWIO` (mirroring the `Attr` family) vs. something else - entirely — see [ADR 17](0017-naming-pass.md). -2. What is the public replacement for `fastcs-secop`'s - `_call_sync_setpoint_callbacks` workaround? Does `WriteIO.send` get an - optional `sync_setpoint` callback argument, or does `AttrW.put` grow a - public method IO authors can call from `send`? -3. Should there be a runtime check (e.g. at `post_initialise`) that - catches "read-only IO passed to a write-capable `Attr`" for cases the - static type checker cannot see (e.g. an `Any`-typed IO built - dynamically, as in `fastcs-secop`'s and `fastcs-PandABlocks`'s - introspection-driven construction)? Both of those drivers build - attributes and their IO from runtime data where static checking cannot - help. -4. `fastcs-PandABlocks`'s `UnitsIO.send` mutates a sibling attribute's - datatype and `fastcs-catio` recovers per-attribute metadata via - `attribute.io_ref` from *outside* the attribute's own `send`/`update` - (`panda_controller.py:_coerce_value_to_panda_type`). With `io_ref` - removed, what is the sanctioned way to recover an attribute's IO-specific - metadata (e.g. `attr.io` becoming a public, typed property)? -5. Do we ship `CallbackReadIO`/`CallbackWriteIO` in `fastcs` core for 1.0, or - leave the "no subclass needed" one-off case entirely to driver authors - using `io=None` plus manual `set_update_callback`? +## Resolved in review (#402) + +- **Runtime check: yes.** Alongside the static type error, a runtime check + (e.g. at `post_initialise`) catches a read-only IO on a write-capable `Attr` + for the dynamically-built `Any`-typed case (`fastcs-secop`, + `fastcs-PandABlocks`). +- **`attr.io` becomes a public, typed property** — the sanctioned way to + recover an attribute's IO-specific metadata from *outside* its `send`/ + `update` (replaces `fastcs-catio`'s `attribute.io_ref` access). +- **No `CallbackReadIO`/`CallbackWriteIO` in core.** The one-off callback case + folds into the unified `attr` factory ([ADR 18](0018-attr-decorator-sugar.md)); + the same decorator/factory covers the read-only and read/write cases. + +## Open questions (awaiting input) + +1. Final class names: `ReadIO`/`WriteIO`/`ReadWriteIO` vs. `AttrRIO`/`AttrWIO`/ + `AttrRWIO` vs. something else — see [ADR 17](0017-naming-pass.md). + *(awaiting @shihab-dls)* +2. Public replacement for `fastcs-secop`'s `_call_sync_setpoint_callbacks`: + an optional `sync_setpoint` argument on `WriteIO.send`, or a public method + on `AttrW` an IO author can call from `send`? *(awaiting @shihab-dls / + @Tom-Willemsen)* diff --git a/docs/explanations/decisions/0015-typed-commands.md b/docs/explanations/decisions/0015-typed-commands.md index a3d6c9b37..9df453dec 100644 --- a/docs/explanations/decisions/0015-typed-commands.md +++ b/docs/explanations/decisions/0015-typed-commands.md @@ -60,6 +60,19 @@ class Ramp(Controller): stop: Command[[], None] # void/void: served everywhere ``` +**Argument and return typing are independent** (not all-or-nothing): + +- *Args*: `[]` (none) · `[DT1, DT2, …]` (positional, known types — validated) · + `Any` (must be a command, signature introspected at runtime). +- *Returns*: `None` · `DT` (a single typed value) · `Any` (introspected). + +Following the [ADR 17](0017-naming-pass.md) `DataType` drop, command +arguments/returns use plain python types + `*Meta` exactly as attributes do +(no metadata ⇒ "use the python type"), and the serialisation machinery is +**shared** with `Attribute`, not duplicated. **Keyword-argument** commands +need a `TYPE_CHECKING` stub trick and are prototyped separately in the spike +[#403](https://github.com/DiamondLightSource/fastcs/issues/403), not here. + ## Consequences - `Command.__call__` gains real `*args`/`**kwargs` forwarding instead of a @@ -76,27 +89,28 @@ class Ramp(Controller): signatures is not achievable for introspection-driven drivers, only for statically-declared ones. This mirrors the same "hint vs. no-hint" tension as [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). -- Command args/return values need datatype validation analogous to - `Attribute`'s `DataType.validate` — whether they reuse the `DataType` - family directly or a separate mechanism is an open question. - -## Open questions - -1. Do command arguments/return values validate through the same `DataType` - family attributes use, or is a separate (lighter-weight, since there's no - "current value" to cache) validation path introduced? -2. For `fastcs-secop`-style dynamically-typed commands, what's the - recommended pattern — `Command[Any, Any]` with manual validation inside - the handler, or a documented way to construct a `Command[P, T]` with `P`/ - `T` determined at runtime (which conflicts with normal generic typing)? -3. Exactly what should the EPICS skip-with-warning message say, and where — - at controller construction, at `post_initialise`, or lazily the first - time a typed command is looked up by the transport? -4. Should typed commands support partial typing (e.g. typed arguments but - void return, or vice versa), or is it all-or-nothing relative to - `Command[[], None]`? -5. Does the REST/GraphQL/Tango serialisation of complex argument/return - types (numpy arrays, `Enum`, `Table`) reuse existing `DataType` - serialisation code from attributes, and if so does that argue for - sharing more machinery between `Attribute` and `Command` than they do - today? +- Command args/return values validate through the **same** python-type + + `*Meta` mechanism as attributes (the `DataType` family is removed, ADR 17) — + one shared validation/serialisation path, no command-specific duplicate. + +## Resolved in review (#402) + +- **Validation shares the attribute path.** With `DataType` dropped (ADR 17), + command args/returns use python types + `*Meta` like attributes; no separate + mechanism, and complex-type serialisation (arrays, `Enum`, `Table`) is shared + with `Attribute`. +- **Args and returns are typed independently** — Args `[]` / `[DT…]` / `Any`; + Returns `None` / `DT` / `Any` (see Decision). Not all-or-nothing. +- **EPICS skip-with-warning fires at IOC startup** — post controller + construction, when the fully populated controllers are handed to the + transports to serve. +- **Keyword-arg commands → spike [#403](https://github.com/DiamondLightSource/fastcs/issues/403)** + (interactive/Opus; needs a `TYPE_CHECKING` stub). Out of scope for core + typed-command work. + +## Open questions (awaiting input) + +1. Are there real `fastcs-secop` devices where you know something is a + `Command` but not its signature until runtime? Determines whether + `Command[Any, Any]` + runtime introspection suffices, or the kw-arg trick + (#403) is truly needed. *(awaiting @Tom-Willemsen)* diff --git a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md index 3c20a85bd..5148edf4c 100644 --- a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md +++ b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md @@ -41,16 +41,18 @@ to using — no reaching into `BaseController` internals. ## Decision **Setpoint cache:** `AttrW` gains an internally-tracked last-applied -setpoint, exposed via a public getter (name TBD — see open questions), -updated whenever `put` is called, independent of whether the underlying -`send` succeeds. This is available to all transports (not just the embedded +setpoint, exposed via a public `AttrW.get_setpoint()` method (mirroring +ophyd-async's `SignalBackend.get_setpoint()`), updated whenever `put` is +called, independent of whether the underlying `send` succeeds. This is available to all transports (not just the embedded connector) as a "what did we last ask for" query distinct from `AttrR.get()` ("what did we last read back"). **Native timestamps (+ severity):** `AttrR.update` accepts an optional timestamp (and, where meaningful, severity) alongside the value, defaulting -to current time if not supplied by the caller. This is FastCS-native, not -EPICS-specific — Tango event pushes and other IO can supply a device-side +to current time if not supplied by the caller. The timestamp/severity pair +**follows bluesky's `Reading` shape but shares no code** with it, and severity +is a **FastCS enum using the same strings as EPICS** alarm severities. This is +FastCS-native, not EPICS-specific — Tango event pushes and other IO can supply a device-side timestamp through the same path a `ReadIO.update` call already uses. The embedded connector stamps receive-time only as an interim measure until this lands, per decision 10 of #388 — this is 1.0 scope, not a follow-up. @@ -65,9 +67,15 @@ transport-serving and interactive-shell logic that stays in `FastCS`/ - Running `initialise()`/`post_initialise()`/`create_api_and_tasks()` once. - Running `connect()` and the initial coroutines. - Starting/stopping the periodic scan tasks. -- Being **idempotent** — safe to call start again after a stop, since the - embedded connector's `connect_real` may run more than once across - reconnects (see [ADR 19](0019-embedded-ophyd-async-connector.md)). +- The **whole lifecycle including reconnect** — calling `Controller.reconnect()` + on scan-task failure; reconnect is owned by the runner, not left + controller-specific. + +The runner is a class with `start()`/`stop()` (ophyd-async calls `start`/`stop`; +an `async with` context manager is added only if it also suits the `FastCS()` +case). **Idempotency is the caller's responsibility**, not the runner's — the +embedded connector's `connect_real` may run more than once across reconnects +(see [ADR 19](0019-embedded-ophyd-async-connector.md)). This, together with `ControllerAPI` and the attribute/command runtime methods (`AttrR.get`/`add_on_update_callback`, `AttrW.put` + cached @@ -89,24 +97,12 @@ the documented stable surface referenced by decision 13 of #388. narrow surface instead of `BaseController` internals — see [ADR 19](0019-embedded-ophyd-async-connector.md). -## Open questions - -1. Setpoint cache accessor name and shape — `AttrW.setpoint` property, - `AttrW.get_setpoint()` method (mirroring `SignalBackend.get_setpoint()`), - or folded into `AttrW.put`'s return value? -2. Timestamp/severity type — reuse a existing convention (e.g. - `ophyd_async`/bluesky's `Reading`/event-model shape) or define a - FastCS-native pair? Decision 12 of #388 already aligns numeric limits - naming with event-model `Limits` — should timestamps/severity follow the - same alignment for consistency? -3. Severity: what are the FastCS-native severity levels, and do they map - 1:1 to EPICS alarm severities, or is EPICS's severity model transport- - specific with FastCS defining its own smaller/different vocabulary? -4. Exact `ControllerRunner` API shape — a class with `start()`/`stop()`, or - `async` context-manager semantics (`async with runner:`)? The embedded - connector needs idempotent start across reconnects; does the chosen shape - make idempotency the caller's responsibility or the runner's? -5. Does `ControllerRunner` own reconnect logic (calling `Controller.reconnect()` - on scan-task failure, as `Controller._create_periodic_scan_coro` does - today), or does that stay controller-specific and out of the runner's - documented surface? +## Resolved in review (#402) + +- **Setpoint accessor:** a `AttrW.get_setpoint()` method (mirrors + `SignalBackend.get_setpoint()`). +- **Timestamp/severity:** follow bluesky's `Reading` shape but **share no + code**; severity is a **FastCS enum using the same strings as EPICS**. +- **`ControllerRunner`:** a class with `start()`/`stop()` (context manager only + if it also suits `FastCS()`); **idempotency is the caller's responsibility**. +- **The runner owns the whole lifecycle, including reconnect.** diff --git a/docs/explanations/decisions/0017-naming-pass.md b/docs/explanations/decisions/0017-naming-pass.md index f0b8989e4..3ac954894 100644 --- a/docs/explanations/decisions/0017-naming-pass.md +++ b/docs/explanations/decisions/0017-naming-pass.md @@ -34,6 +34,14 @@ after 1.0 when they become a deprecation cycle. ## Decision +> **Review update (#402): `DataType` is dropped** (see +> [ADR 15](0015-typed-commands.md)). The renames below now live on python +> types + `*Meta` typed dicts, not on `DataType` classes, and this pass folds +> into the `AttributeIO` rework ([ADR 14](0014-attribute-io-rw-rework.md) / +> issue #392) rather than a separate late PR. `Array1D`/`Table` become *both* +> the hint and the runtime structure passed around as the datatype — there is +> no separate `Waveform`/`DataType` object to map to. + 1. **`prec` → `precision`.** Rename across `_Numeric`/`Float`/wherever `prec` appears (transports, docs, snippets). No behaviour change. 2. **Limits alignment.** Align `min`/`max`/`min_alarm`/`max_alarm` naming @@ -76,30 +84,15 @@ mechanism — not part of this ADR. attribute declarations; procedural construction with `Waveform(...)`/ `Table(...)` DataType instances is unchanged. -## Open questions - -1. Does the Limits alignment keep four flat fields (just renamed to match - event-model terms) or restructure into an actual `Limits`-like nested - object? The latter is a bigger, more disruptive change to - `DataType.validate` and every downstream driver constructing `Float(...)` - with keyword limits. -2. Which event-model `Limits` categories does FastCS need — - control/display/alarm/warning all four, or a subset? EPICS records only - naturally distinguish alarm vs. display/control limits; does the mapping - from four FastCS fields to N event-model categories lose or need to - invent information for some transports? -3. Is `precision` an `int` (decimal places, as `prec` is today) or does - aligning with event-model conventions change its meaning/type too? -4. For `Array1D`/`Table` hints: is `Array1D[np.int32]` a real usable type at - both class-definition time (for `ControllerFiller` to scan) and at - type-checking time (for pyright), or a `TypeAlias`/`Annotated` wrapper - around `Waveform`? What does the two-way mapping (hint → `ControllerFiller` - constructs a `Waveform`; introspection-provisioned `Waveform` → does the - hint still validate it, per decision in - [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) - open question 1) look like precisely? -5. Should this rename land in the same PR as - [ADR 14](0014-attribute-io-rw-rework.md) (since both touch `DataType`- - adjacent code and every downstream driver already has to touch these - files), or stay a separate, later PR per §8 work-plan ordering (item 5, - after items 1-4)? +## Resolved in review (#402) + +1. **Limits are nested**, not four flat fields. +2. **All four categories (control/display/alarm/warning), all optional**, with + inheritance: supply none ⇒ all unbounded; Display but not Control ⇒ Control + inherits Display (for writeable); Alarm but not Warning ⇒ Warning inherits + Alarm; both ⇒ assert Warning ⊆ Alarm; otherwise unspecified ⇒ unbounded. +3. **`precision` stays an `int`** (decimal places). +4. **`Array1D` is both the hint and the runtime structure** — with `DataType` + dropped it falls out in the wash; there is no `Waveform` object to map to. +5. **Where it lands is the implementer's choice** — folds naturally into the + `AttributeIO`/DataType-drop PR (#392). diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index 0a79f8f25..f5ca998b0 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -42,8 +42,15 @@ removes the latter but keeps `@command`/`@scan`. ## Decision -Add `@attr_r`/`@attr_rw` (and, for symmetry, whatever `@attr_w`-only case -makes sense) as pure sugar over `AttrR`/`AttrW`/`AttrRW` plus a generated +> **Review update (#402): the decorator is `@attr`, mirroring `@property`.** +> `@attr` on the getter, `@voltage.setter` on the writer (not `@attr_r`/ +> `@attr_rw`/`.send`). It unifies with the callback IO from +> [ADR 14](0014-attribute-io-rw-rework.md): `self.x = attr(getter=…, setter=…)` +> is the `__init__` spelling of the same thing. `AttrW`-only (write with no +> paired getter) is rare, so it is written longhand rather than given its own +> decorator. + +Add `@attr` as pure sugar over `AttrR`/`AttrRW` plus a generated callback-based `io=`, built on the same `Unbound*`-style bind machinery as `@command`/`@scan` — fresh objects per instance, no prototype/deepcopy hazard, consistent with keeping this a class-body citizen under @@ -51,11 +58,11 @@ hazard, consistent with keeping this a class-body citizen under ```python class PowerSupply(Controller): - @attr_rw(units="V", update_period=0.5) # dtype inferred from -> float + @attr(units="V", update_period=0.5) # dtype inferred from -> float async def voltage(self) -> float: return await self._conn.query("V?") - @voltage.send + @voltage.setter async def voltage(self, value: float) -> None: await self._conn.send(f"V={value}") ``` @@ -70,10 +77,9 @@ class PowerSupply(Controller): etc.) map onto the equivalent `DataType`/`ReadIO`/`WriteIO` constructor arguments from [ADR 14](0014-attribute-io-rw-rework.md) — this is sugar over that mechanism, not a parallel one. -- `@attr_rw`'s `.send` decorator mirrors the `@voltage.send` pattern shown - above (property-style, matching `@property`/`@x.setter`), giving the +- `@attr`'s `.setter` decorator mirrors `@property`/`@x.setter`, giving the read+write pair a single logical name (`voltage`) with two decorated - methods. + methods. (`.send` is not used.) - This degrades gracefully into the full `io=` object form for protocol families with more complex needs, and into [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s @@ -92,11 +98,10 @@ class PowerSupply(Controller): - New driver code for the common "one attribute, one device call" case gets noticeably shorter — closing the gap #388 §7.5 identifies against PyTango. -- The generated `io=` object needs a name/shape (an internal - `CallbackReadIO`/`CallbackWriteIO`-alike, per - [ADR 14](0014-attribute-io-rw-rework.md)'s open question 5) — this ADR's - sugar and that ADR's escape hatch should likely share the same underlying - callback-IO implementation rather than duplicating it. +- The generated callback `io=` **is** the unified callback mechanism from + [ADR 14](0014-attribute-io-rw-rework.md) (which no longer ships separate + `CallbackReadIO`/`CallbackWriteIO`): `@attr` and `attr(getter=…)` are two + spellings over one implementation. - Adds a third way to declare an attribute (bare hint + filler; explicit `AttrRW(..., io=...)`; `@attr_rw` sugar) — the docs need to be clear about when to reach for which, so this doesn't become three equally-weighted @@ -107,25 +112,16 @@ class PowerSupply(Controller): small and independent of the `ControllerFiller` work — so it can land early and not block on [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). -## Open questions - -1. Exact decorator names — `@attr_r`/`@attr_rw` as #388 proposes, or - something more explicit (`@readable_attribute`?) — and whether a - write-only `@attr_w` variant is worth adding for symmetry given `AttrW` - without a paired getter is a rarer shape in practice. -2. How are `min`/`max`/`precision` (post [ADR 17](0017-naming-pass.md)) - and other `DataType`-level metadata passed through the decorator's - keyword arguments — do they get their own decorator kwargs, or does the - decorator only take IO-shaped kwargs (`update_period`) and require - dropping to explicit `AttrRW(...)` construction for richer datatype - metadata? -3. Does `@attr_rw` support the `Array1D`/`Table` hint spellings from - [ADR 17](0017-naming-pass.md), or is decorator sugar scoped to scalar - datatypes only for 1.0? -4. Should `ControllerFiller` treat `@attr_rw`-decorated methods specially - (they don't need filling — they're already fully constructed at bind - time), or are they simply invisible to the filler the same way - `@command`/`@scan` are today? -5. Does the getter's docstring become the attribute's `description`, - mirroring how `Method._docstring` already captures `getdoc(fn)` for - `@command`/`@scan`? +## Resolved in review (#402) + +1. **Decorator is `@attr` + `@x.setter`** (property-mirroring), not + `@attr_r`/`@attr_rw`/`.send`. No dedicated write-only decorator — `AttrW` + alone is rare, written longhand. +2. **Datatype/limits metadata passes via decorator kwargs** (`precision`, + `units`, limits — the ADR 17 `*Meta` fields). +3. **Supports the ADR 17 `Array1D`/`Table` hints.** +4. **`@attr`-decorated attrs are treated specially by the filler** — already + defined, so not shadowed; a clash between an introspected name and a + decorated name raises. +5. **Yes — the getter's docstring becomes the attribute's `description`** + (as `@command`/`@scan` already do). diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md index 9614421df..4b44af193 100644 --- a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -86,8 +86,10 @@ Mechanics, directly mirroring `PviDeviceConnector`/`TangoDeviceConnector`: usage stays free (no FastCS controller/connection is instantiated at all). - Lifecycle (decision 8 of #388): the connector owns the runner; shutdown via an `atexit` hook plus an explicit `await connector.shutdown()`, which - cancels scan tasks and calls `Controller.disconnect()`. Upstream - `Device.disconnect()` is a follow-up (item 8 in #388 §8), not blocking. + cancels scan tasks and calls `Controller.disconnect()`. **The + `Device.disconnect()` proposal is dropped** — reconnect is + `Device.connect(force_reconnect=True)`, and the only disconnect we want is + `atexit` (review #402). - Embedded + transports simultaneously (decision 9 of #388, e.g. a CA GUI running next to a bluesky plan) is explicitly out of scope for the first cut, but the `ControllerRunner` is designed so a transport list can be @@ -111,14 +113,15 @@ Backend mappings (from #388 §5, grounded against the researched Datatype mapping: `Int`/`Float`/`Bool`/`String` → `int`/`float`/`bool`/`str`; `Waveform(array_dtype, shape)`/`Array1D` hint (per [ADR 17](0017-naming-pass.md)) → ophyd-async `Array1D[dtype]`; `Enum(cls)` → -the enum class itself. Two mismatches flagged as **prototype risk** in #388 -and carried into this ADR unresolved: +the enum class itself. Resolved in review (#402): -- ophyd-async constrains enums to `EnumTypes` (`StrictEnum`/`SubsetEnum`/ - `SupersetEnum`); FastCS accepts any `enum.Enum`. -- fastcs `Table` vs. ophyd-async `Table` (pydantic-based) — mapping is - best-effort, mismatches should be flagged early rather than silently - coerced. +- **Enums:** un-hinted enum classes introspect at runtime and drop to a string + datatype retaining the choices as metadata; hint-typed enums require the + author to duplicate as a `StrictEnum`/`SubsetEnum`/`SupersetEnum` (as they + would for remote FastCS) for now — revisit once there are use cases. +- **`Table`:** a real bidirectional converter **is in scope for the first + cut**, used as the opportunity to bring the FastCS and ophyd-async `Table` + implementations closer together. ## Consequences @@ -140,29 +143,23 @@ and carried into this ADR unresolved: (`fastcs.demo.simulation`) is the existing sim device ophyd-async tests against — no new simulated device is needed for the first cut. -## Open questions - -1. Enum conversion: does the connector require FastCS `Enum` datatypes used - with embedding to be `StrictEnum`/`SubsetEnum`/`SupersetEnum` subclasses - (pushing a constraint back onto FastCS driver authors who want embedding - support), or does it do runtime conversion/wrapping, and what happens to - values that don't fit ophyd-async's stricter model? -2. `Table` mapping: is a real bidirectional pydantic-model ↔ fastcs-`Table` - converter in scope for the first cut, or is `Table` explicitly - unsupported/best-effort-only initially, with a hard error on mismatch - rather than silent coercion? -3. Where does `@scan`-derived state that isn't exposed as a `Signal` go — - is it simply invisible to ophyd-async (server-side only, as the mapping - table states), or does some `@scan` output need a path to surface as a - `SignalR` (e.g. `fastcs-eiger`'s `update_voltages` `@scan` feeding - per-ramp `AttrR`s — those `AttrR`s are visible, but would a *pure* - `@scan`-only value ever need exposing)? -4. How does `embedded_fastcs_connector` handle a `Controller` that raises - during `initialise()` (e.g. a device that's unreachable at embed time) - — does `connect_real` propagate the exception directly to - `Device.connect()`, retry, or something else? -5. Should the embedded connector's shutdown (`atexit` + explicit - `await connector.shutdown()`) also be triggered by ophyd-async's own - `Device.disconnect()` once that upstream work lands (#388 §8 item 8), and - does that imply `ControllerRunner.stop()` needs to be safely callable - from a synchronous `atexit` context as well as an async one? +## Resolved in review (#402) + +- **Enums:** un-hinted → runtime-introspect, drop to string keeping choices as + metadata; hinted → require `StrictEnum`/`SubsetEnum`/`SupersetEnum` + duplication for now, revisit with use cases. +- **`Table`:** bidirectional converter in scope for the first cut; use it to + converge the two `Table` implementations. +- **Errors:** FastCS gains a `ConnectionFailedError` (raised when the device + doesn't respond); the connector converts it to `NotConnectedError` and keeps + retrying to connect in the background. All other errors surface unconverted. +- **Disconnect dropped:** reconnect is `Device.connect(force_reconnect=True)`; + the only disconnect is `atexit`. No `Device.disconnect()` proposal (so #388 + §8 item 8 / issue #401 is rewritten accordingly). + +## Open questions (awaiting input) + +1. Where does `@scan`-derived state that isn't exposed as a `Signal` go? All + attribute data already lives in `Attr` instances mapped to `Signal`s, so it + may be that `@scan` only drives updates and nothing extra needs surfacing — + needs confirming. *(awaiting @shihab-dls)* From 8979bd00e2383c11014e365998f57c7a2bda9e19 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 21 Jul 2026 10:35:26 +0000 Subject: [PATCH 10/36] docs: spell out the *Meta metadata model in ADRs (DataType replacement) Define the *Meta TypedDicts + Unpack overloads for the procedural spelling, the superset Meta for generic extras (SCPIParam), attribute-stored resolved meta, and the filler's runtime validation of annotated metadata against the datatype. Clarify in ADR 13 that hinted attributes exist after __init__ (filler creates them unfilled), not after initialise(). Module home for the new names deferred to #406 (top-level API namespace: flat vs nested). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- ...-procedural-split-and-controller-filler.md | 25 +++++++++--- .../decisions/0014-attribute-io-rw-rework.md | 40 +++++++++++++++++-- .../decisions/0017-naming-pass.md | 5 +++ .../decisions/0018-attr-decorator-sugar.md | 5 ++- .../0019-embedded-ophyd-async-connector.md | 2 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md index 847de3a58..2de4e7d63 100644 --- a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -77,6 +77,12 @@ bare type hints only; instance scope = procedural construction.** Concretely: own extras vocabulary the same way ophyd-async's `PvSuffix`/`TangoPolling` do. Core FastCS defines **no** extras vocabulary for 1.0 (decision 3 of #388). +- When a child is filled from an `Annotated[Attr[T], extras]` hint, the filler + **runtime-validates** the metadata the extras carries (`FloatMeta`, or a + protocol object's `.meta` such as `SCPIParam(...).meta`) against the datatype + `T` — e.g. `precision` supplied for a `str` raises. This is the runtime + counterpart to the static `Unpack[FloatMeta]` check on the procedural `Attr*` + constructors (see [ADR 14](0014-attribute-io-rw-rework.md)). - The refined rule from decision 14 of #388: *class body = declarations + decorated behaviour; instance scope = construction with data.* This keeps `@command`/`@scan`, and the new `@attr_r`/`@attr_rw` sugar, as class-body @@ -96,22 +102,29 @@ class TemperatureRampController(Controller): self.start = AttrRW(Int(), io=TempIO(conn, "S", suffix)) ``` -**Declarative hint + filler** — the value is *promised* by a hint and -provisioned by introspection at connect time (the Eiger-like case): +**Declarative hint + filler** — the value is *promised* by a hint; the +`ControllerFiller` (run from `Controller.__init__`) creates it as an +**unfilled** `Attribute` so it **exists as soon as `__init__` returns**, and +`initialise()` later *fills* it (provisions `io` + metadata) by introspection: ```python class OdinDetector(Controller): - frames: AttrRW[int] # must exist by the end of initialise() + frames: AttrRW[int] # created UNFILLED by the filler in __init__; + # self.frames EXISTS after __init__, before initialise() async def initialise(self) -> None: + # introspection FILLS the already-created hinted attrs (io + metadata), + # and may add wholly-undeclared dynamic attrs (which carry no hint) for name, meta in await self._query_parameter_tree(): - self.filler.fill_attribute(name, ...) + self.filler.fill_attribute(name, ...) # validates meta vs datatype + self.filler.check_filled() ``` **The rule** (identical to ophyd-async's rule for `Signal`s): *at the end of `__init__`, any Attribute referenced in code — and therefore carrying a type -hint — must exist.* Only `__init__` is serial; `initialise()` may then run in -parallel across controllers. +hint — must exist* (the filler guarantees this for hinted children by creating +them unfilled during `__init__`). Only `__init__` is serial; `initialise()` +may then run in parallel across controllers. Introspecting controllers keep working as today's `initialise()` + `add_attribute` pattern. The fully-dynamic case — where the *set* of diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index fc9e2ed0f..8c762fcda 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -105,10 +105,42 @@ declared without a boilerplate `__init__`. `CallbackWriteIO` classes. The same `attr` covers the read-only and read/write callback cases, so the two adapters are not shipped. -> Datatype spelling: the `Float()` etc. in the examples below predate -> [ADR 17](0017-naming-pass.md); read them as the ADR-17 python-type + `*Meta` -> form (`AttrRW(float, precision=3, io=...)`), since the `DataType` family is -> removed. +### Datatype metadata: the `*Meta` TypedDicts + +`DataType` classes are gone (ADR 15/17). The metadata they carried (precision, +units, nested limits, …) moves to a per-datatype `TypedDict` — `FloatMeta`, +`IntMeta`, `StrMeta`, `BoolMeta`, `EnumMeta`, `Array1DMeta`, `TableMeta` — and +**the resolved metadata is stored on the `Attribute` itself** (`attr.meta`), +not on a separate datatype object. Every transport/connector that read +`attr.datatype.precision`/`.units`/`.limits`/`.choices` now reads `attr.meta` +(enum `choices` come from the python type; `EnumMeta` is display-only). + +Two spellings, two validation layers: + +- **Procedural (statically checked):** the `Attr*` constructors are overloaded + per datatype so the right `*Meta` is unpacked into `**kwargs`: + + ```python + # conceptually, one overload per datatype: + def AttrRW(dtype: type[float], *, io=..., **kwargs: Unpack[FloatMeta]) -> AttrRW[float]: ... + + self.temperature = AttrRW(float, precision=3, units="deg", io=TempIO(...)) + # AttrRW(str, precision=3) is a static type error + ``` + +- **Declarative (runtime-checked by the filler):** + `Annotated[AttrRW[float], FloatMeta(precision=3)]` (rare) or + `Annotated[AttrRW[float], SCPIParam("P", precision=3)]` (common). Neither + ties the metadata to the `AttrRW[...]` type param statically, so + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s + `ControllerFiller` validates it against the datatype at fill time. A generic + extras object takes the **superset** `Meta` TypedDict — + `SCPIParam(param: str, **kwargs: Unpack[Meta])` (`Meta` being the union of + `FloatMeta`/`StrMeta`/… fields, all optional) — stores a `.meta`, and the + filler passes that `.meta` into the constructed `AttrRW`. + +The `*Meta` module location is deferred to the public-API-namespace decision +(#406); land it provisionally until then. Migration is mechanical for the common case (an old `AttributeIO` subclass absorbs its `AttributeIORef`'s fields into its own `__init__` and is diff --git a/docs/explanations/decisions/0017-naming-pass.md b/docs/explanations/decisions/0017-naming-pass.md index 3ac954894..a781e002e 100644 --- a/docs/explanations/decisions/0017-naming-pass.md +++ b/docs/explanations/decisions/0017-naming-pass.md @@ -41,6 +41,11 @@ after 1.0 when they become a deprecation cycle. > issue #392) rather than a separate late PR. `Array1D`/`Table` become *both* > the hint and the runtime structure passed around as the datatype — there is > no separate `Waveform`/`DataType` object to map to. +> +> The concrete `*Meta` mechanism (per-datatype `TypedDict`s, the superset +> `Meta` for extras, `attr.meta` storage on the attribute, `Unpack` overloads) +> is specified in [ADR 14](0014-attribute-io-rw-rework.md); the module home for +> these public names is decided in #406. 1. **`prec` → `precision`.** Rename across `_Numeric`/`Float`/wherever `prec` appears (transports, docs, snippets). No behaviour change. diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index f5ca998b0..b4b7a6d07 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -117,8 +117,9 @@ class PowerSupply(Controller): 1. **Decorator is `@attr` + `@x.setter`** (property-mirroring), not `@attr_r`/`@attr_rw`/`.send`. No dedicated write-only decorator — `AttrW` alone is rare, written longhand. -2. **Datatype/limits metadata passes via decorator kwargs** (`precision`, - `units`, limits — the ADR 17 `*Meta` fields). +2. **Datatype/limits metadata passes via decorator kwargs**, typed with + `Unpack[…Meta]` (`precision`, `units`, limits — the ADR 14/17 `*Meta` + fields), validated against the getter's return type. 3. **Supports the ADR 17 `Array1D`/`Table` hints.** 4. **`@attr`-decorated attrs are treated specially by the filler** — already defined, so not shadowed; a clash between an introspected name and a diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md index 4b44af193..6bdd3ca87 100644 --- a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -104,7 +104,7 @@ Backend mappings (from #388 §5, grounded against the researched | `SignalBackend.set_callback` | `AttrR.add_on_update_callback(cb, always=True)`; stamped per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | | `SignalBackend.put` | `AttrW.put(value)` | | `SignalBackend.get_setpoint` | `AttrW` cached setpoint, [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | -| `SignalBackend.get_datakey` | `Attribute.datatype` → `SignalMetadata` (units, precision, limits, choices) + `make_datakey` | +| `SignalBackend.get_datakey` | `attr.meta` (units, precision, limits) + python-type/enum choices → `SignalMetadata` + `make_datakey` | | `CommandBackend.execute`/`.signature` | `Command.__call__` / captured `Signature`, [ADR 15](0015-typed-commands.md) | | `SignalBackend.source` | e.g. `fastcs://.` | | child `Device` / `DeviceVector` | sub-`Controller` / `ControllerVector` | From 85df0f595bff5d40197104f6186b913c1f51e10b Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 21 Jul 2026 14:15:41 +0000 Subject: [PATCH 11/36] docs: ADR 15 - drop Command[Any, Any], no partial typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @Tom-Willemsen on PR #402 (r3621453680): SECoP devices are discovered entirely from an over-the-wire `describe`, so you never statically know something is a command without knowing its signature. There is no "known command, unknown args" middle case — P/T are either completely known (static `Command[P, T]`) or the whole structure is unknown (built at runtime). Remove the `Any` args/returns option and the `Command[Any, Any]` consequence; resolve the open question awaiting @Tom-Willemsen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- .../decisions/0015-typed-commands.md | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/docs/explanations/decisions/0015-typed-commands.md b/docs/explanations/decisions/0015-typed-commands.md index 9df453dec..f4d488da7 100644 --- a/docs/explanations/decisions/0015-typed-commands.md +++ b/docs/explanations/decisions/0015-typed-commands.md @@ -62,9 +62,20 @@ class Ramp(Controller): **Argument and return typing are independent** (not all-or-nothing): -- *Args*: `[]` (none) · `[DT1, DT2, …]` (positional, known types — validated) · - `Any` (must be a command, signature introspected at runtime). -- *Returns*: `None` · `DT` (a single typed value) · `Any` (introspected). +- *Args*: `[]` (none) · `[DT1, DT2, …]` (positional, known types — validated). +- *Returns*: `None` · `DT` (a single typed value). + +There is **no partial `Command[Any, Any]`** — a statically-declared `Command` +always has its parameter and return types fully known. The alternative is not +a half-known command but a fully-dynamic controller: a driver that knows +*nothing* statically (`fastcs-secop`, discovering everything from an +over-the-wire `describe`) does not annotate a `Command` at all — it builds the +whole structure, attributes and commands alike, at runtime. So `P`/`T` are +either **completely known** (static declaration) or the **whole structure is +unknown** (runtime construction); there is no in-between case where you know +something is a command but not its signature. This is the same "hint vs. +no-hint" split as [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +for attributes — with no partial hint. Following the [ADR 17](0017-naming-pass.md) `DataType` drop, command arguments/returns use plain python types + `*Meta` exactly as attributes do @@ -83,12 +94,14 @@ need a `TYPE_CHECKING` stub trick and are prototyped separately in the spike - EPICS transports (`transports/epics/ca`, `transports/epics/pva`) need a capability check at controller-API-build time, producing a startup-time warning log rather than a runtime failure per typed-command call. -- `fastcs-secop`'s dynamically-typed command args/results likely still need - `Command[Any, Any]` or a per-instance generated type, since SECoP's - `datainfo` is only known at connect time — full static typing of command - signatures is not achievable for introspection-driven drivers, only for - statically-declared ones. This mirrors the same "hint vs. no-hint" tension - as [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). +- `fastcs-secop` (and any introspection-driven driver) does **not** get a + `Command[Any, Any]`. Since SECoP's `describe` reveals the entire structure + only at connect time — there is no static declaration of *anything*, let + alone a command of unknown signature — such drivers build their commands + programmatically at runtime, each carrying a concrete signature derived from + the wire `datainfo`. Static `Command[P, T]` is for statically-declared + controllers; fully-dynamic drivers construct commands (or keep the existing + `SecopCommandController` explode-to-PVs workaround) at runtime instead. - Command args/return values validate through the **same** python-type + `*Meta` mechanism as attributes (the `DataType` family is removed, ADR 17) — one shared validation/serialisation path, no command-specific duplicate. @@ -99,18 +112,19 @@ need a `TYPE_CHECKING` stub trick and are prototyped separately in the spike command args/returns use python types + `*Meta` like attributes; no separate mechanism, and complex-type serialisation (arrays, `Enum`, `Table`) is shared with `Attribute`. -- **Args and returns are typed independently** — Args `[]` / `[DT…]` / `Any`; - Returns `None` / `DT` / `Any` (see Decision). Not all-or-nothing. +- **Args and returns are typed independently** — Args `[]` / `[DT…]`; + Returns `None` / `DT` (see Decision). Not all-or-nothing, but each is fully + known — there is no `Any` middle case. +- **No partial `Command[Any, Any]`.** @Tom-Willemsen confirmed on + [#402](https://github.com/DiamondLightSource/fastcs/pull/402#discussion_r3621453680) + that SECoP devices are discovered entirely from an over-the-wire `describe`: + you never statically know something is a command but not its signature — you + either know the full `Command[P, T]` or you know nothing at all and build the + whole controller at runtime. `P`/`T` are therefore completely known or the + whole structure is unknown, with no in-between. - **EPICS skip-with-warning fires at IOC startup** — post controller construction, when the fully populated controllers are handed to the transports to serve. - **Keyword-arg commands → spike [#403](https://github.com/DiamondLightSource/fastcs/issues/403)** (interactive/Opus; needs a `TYPE_CHECKING` stub). Out of scope for core typed-command work. - -## Open questions (awaiting input) - -1. Are there real `fastcs-secop` devices where you know something is a - `Command` but not its signature until runtime? Determines whether - `Command[Any, Any]` + runtime introspection suffices, or the kw-arg trick - (#403) is truly needed. *(awaiting @Tom-Willemsen)* From 8a4e4a8bfe3dfa269f64ed609093bfb70363bbe4 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Wed, 22 Jul 2026 10:32:27 +0000 Subject: [PATCH 12/36] docs: consolidate examples into fastcs.demo; SCPIParam one-spec design Fold the top-level examples/ scaffold into the fastcs.demo package (mirrors ophyd-async; examples install with fastcs[demo] and become the single source of the tutorial code). Replace the stale 3-style scaffold README with the final 5-example hello-world -> complicated-device ladder (two backends: temperature sim for steps 1-4, cut-down Eiger REST sim for step 5), keyed to issues #398/#404/#390/#405/#391. ADR 0014: spell out the "one spec object per declaratively-filled attribute" design - SCPIParam carries the binding token AND all metadata via Unpack[Meta], is the exclusive spec source (filler does not merge a separate *Meta extra), and pays for its ergonomics with runtime validation. Record why it is SCPIParam not SCPIMeta (a binding extra you instantiate, sibling of PvSuffix/TangoPolling, not a Meta TypedDict) and that it lives in the demo/ protocol layer, not core (decision 3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- .../decisions/0014-attribute-io-rw-rework.md | 27 ++++++++ examples/README.md | 65 ------------------- examples/__init__.py | 4 -- src/fastcs/demo/README.md | 61 +++++++++++++++++ 4 files changed, 88 insertions(+), 69 deletions(-) delete mode 100644 examples/README.md delete mode 100644 examples/__init__.py create mode 100644 src/fastcs/demo/README.md diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 8c762fcda..3bcb981cd 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -139,6 +139,33 @@ Two spellings, two validation layers: `FloatMeta`/`StrMeta`/… fields, all optional) — stores a `.meta`, and the filler passes that `.meta` into the constructed `AttrRW`. +**One spec object per declaratively-filled attribute.** A protocol extra like +`SCPIParam` is the *single place* an attribute's whole specification is +written: both the protocol binding (the command token, `"P"`) and all its +generic metadata (`description`, `precision`, `units`, limits…) via +`**Unpack[Meta]`. The filler treats that extra as the **exclusive** spec +source for its attribute — it does **not** also merge a separate +`FloatMeta`/`Meta` extra sitting on the same `Annotated[...]` hint, so there +is no precedence question to resolve. The trade this accepts is deliberate: +routing metadata through the superset `Meta` (not a per-datatype +`Unpack[FloatMeta]`) means correctness is the filler's **runtime** job, not a +static check — a separate `Annotated` extra cannot tie its `**Meta` to the +`AttrRW[...]` datatype param, and making the extra generic +(`SCPIParam[float](...)`) only forces the user to restate a type already in +the hint. So the declarative path pays for its ergonomics with runtime +validation; the filler's error must name the attribute and field (e.g. +"`precision` is not valid for `str` attribute `device_id`"). + +Naming: the extra is `SCPIParam` (a binding object you *instantiate* as an +`Annotated` extra), **not** `SCPIMeta` — the `*Meta` suffix is reserved for +the metadata TypedDicts you `Unpack` (`FloatMeta`, `Meta`), a different kind +of Python object. `SCPIParam` is a sibling of ophyd-async's +`PvSuffix`/`TangoPolling`, not of `SignalMetadata`. It is **not** part of core +FastCS (decision 3: core defines no extras vocabulary for 1.0) — it lives in a +protocol layer; the demo package ships an example `SCPIController` + +`SCPIParam` to show how a third party builds one on the filler's +`(child, extras)` mechanism. + The `*Meta` module location is deferred to the public-API-namespace decision (#406); land it provisionally until then. diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index d769ffeef..000000000 --- a/examples/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# examples/ - -This package is the **living review artifact** for the FastCS / ophyd-async -API-convergence refactor tracked in -[issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) and its -[ADRs](../docs/explanations/decisions/) (0013-0019). - -It exists so that every framework PR in the refactor has something concrete -to run against, alongside the unit test suite: three example controllers, -each written in one of the three styles the refactor is converging FastCS -towards. As each framework PR lands (`AttributeIO` rework, `ControllerFiller`, -typed commands, the setpoint cache/timestamps/`ControllerRunner`, the naming -pass, `@attr_rw` sugar), the example(s) it affects are updated in the *same* -PR, so `examples/` always reflects current `main` and stays green under -`uv run --locked tox`. It is not a tutorial or documentation snippet source — -those live under `docs/`. - -## The three styles - -1. **IORef temperature controller** (`examples/example_1_ioref/`, tracked by - the `DRAFT: Example 1` sub-issue of #388). A deliberately-messy adaptation - of `src/fastcs/demo/controllers.py`, written against **whatever the - current API is** at the time it's updated. It starts on the *pre-refactor* - `AttributeIORef`/class-scope-instance API and is gradually cleaned up as - each framework PR lands — e.g. once - [ADR 14](../docs/explanations/decisions/0014-attribute-io-rw-rework.md)'s - `AttributeIO` rework merges, this example's `io_ref=`/`ios=[...]` wiring - is updated to `io=` in that same PR. This is intentional: it is the - baseline that proves each framework PR doesn't break a real (if simple) - driver, and it visibly tracks the migration path a downstream repo like - `fastcs-eiger` or `fastcs-catio` would need to follow. - -2. **Introspectable Eiger-style controller** (`examples/example_2_introspectable/`, - tracked by the `DRAFT: Example 2` sub-issue of #388). Mirrors a REST API - the way `fastcs-eiger`, `fastcs-secop`, and `fastcs-PandABlocks` do today: - attributes are built from data queried at `initialise()` time, not - declared as class-scope instances. This is the example that exercises - [ADR 13](../docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md)'s - `ControllerFiller` and bare-hint declarations once that PR lands. - -3. **PyTango-style `@attr_rw` device** (`examples/example_3_decorator/`, - tracked by the `DRAFT: Example 3` sub-issue of #388, blocked on the - `@attr_rw` decorator-sugar issue). A trivial getter/setter device in the - style of - [ADR 18](../docs/explanations/decisions/0018-attr-decorator-sugar.md), - demonstrating the simple case FastCS needs to match PyTango on. - -## Status - -**Scaffold only.** This PR (the ADR seed) adds this package, its structure, -and this README — it does not implement the framework changes or the three -example controllers themselves. Each example is implemented by its own -tracked sub-issue of #388, against the framework API as it exists once that -issue's dependencies have landed. See the `Blocked by:` lines on each -`DRAFT:` sub-issue for the landing order. - -## Keeping it green - -Every framework PR that touches `src/fastcs/` and affects one of these three -styles must update the corresponding example(s) in the same PR, and -`uv run --locked tox` must pass. This is the acceptance criterion listed on -every sub-issue of #388 for exactly this reason: it keeps the examples -honest as a description of "how do I actually write a FastCS driver today," -rather than letting them drift out of sync with the framework the way -standalone documentation snippets can. diff --git a/examples/__init__.py b/examples/__init__.py deleted file mode 100644 index 7cb4edb3d..000000000 --- a/examples/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Living review artifacts for the #388 API-convergence refactor. - -See ``examples/README.md`` for what this package is and how it's used. -""" diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md new file mode 100644 index 000000000..9651ca596 --- /dev/null +++ b/src/fastcs/demo/README.md @@ -0,0 +1,61 @@ +# `fastcs.demo` + +The demo package ships FastCS's **living example controllers** for the +ophyd-async / FastCS API-convergence refactor +([issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), ADRs +0013–0019). Consolidated here (rather than a top-level `examples/` package) to +mirror ophyd-async, so the examples install with `fastcs[demo]` and can be run, +imported, and — crucially — used as the **single source of the tutorial code**. + +These modules are the canonical source the tutorials `literalinclude` from +(one tutorial per example, see the docs `tutorials/`). They are kept green +under `uv run --locked tox`, so the tutorials cannot drift from the framework: +every framework PR that changes an API updates the example(s) it affects in the +*same* PR. This replaces the old "hand-authored `docs/snippets/` that drift" +approach — the examples are the docs. + +## The five examples — a hello-world → complicated-device ladder + +Two hardware backends: a temperature-controller sim (steps 1–4) and a +cut-down Eiger REST sim (step 5). Step 1 is pure-soft (no backend). Each rung +introduces exactly one new concept. + +| # | Module | Concept | Backend | Issue | +|---|--------|---------|---------|-------| +| 1 | `hello_world.py` | pure-soft `@attr`/`@attr_rw` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | +| 2 | `temperature_attr.py` | callback getter/setter via the `attr` factory in `__init__` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404) | +| 3 | `controllers.py` | reusable per-attribute `io=` `ReadWriteIO` objects, sub-controllers/vectors, `@scan`/`@command` | temperature sim | [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | +| 4 | `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each `io` from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | +| 5 | `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | + +Notes: + +- **Steps 2 vs 3** are the same device wired two ways — inline per-attribute + getter/setter, then the same IO factored into a reusable `io=` object — a + natural refactoring story on one backend. +- **Step 4 is deliberately *not* introspectable.** A SCPI device does not + describe itself, which is exactly why you hand-annotate: the metadata lives + in your Python (`SCPIParam("P", precision=3, …)`), not on the wire. Do **not** + invent SCPI introspection — that would erase the contrast with step 5. The + example `SCPIController`/`SCPIParam` vocabulary lives *here in the demo*, not + in core FastCS (decision 3: core ships no extras vocabulary for 1.0); it + demonstrates how a protocol layer builds on the filler's `(child, extras)` + mechanism. +- **Step 5 uses a separate Eiger REST backend on purpose.** Introspection earns + its complexity only when a device's parameters aren't knowable at author time + (a detector, not a fixed-command temp controller). The backend switch *is* + the lesson — "small & known → declare; large & self-describing → introspect" + — and the REST sim also exercises an HTTP client backend the temp examples + never touch, matching real downstream drivers (`fastcs-eiger`, `fastcs-secop`, + PandABlocks). + +## Baselines vs framework PRs + +Steps 2, 3, 5 have current-API baselines that can be written **now** +(deliberately messy against the pre-refactor API) and are cleaned up as each +framework PR lands. Steps 1 and 4 need framework work first (`attr` factory +#397; `ControllerFiller` #394). See each issue's `Blocked by:` line. + +`literalinclude` region markers are added to each module as part of writing its +tutorial (the umbrella docs pass, +[#408](https://github.com/DiamondLightSource/fastcs/issues/408)), not up front. From 281819580d4582ff093ad5fbd0e162440012ff01 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Wed, 22 Jul 2026 15:54:31 +0000 Subject: [PATCH 13/36] docs: getter/setter IO model; @attr decorator-only; 4-tutorial ladder Fold in the 2026-07-22 review decisions: ADR 0014: io= objects (ReadIO/WriteIO/ReadWriteIO) superseded by getter/setter callables on AttrR/AttrW/AttrRW. getter() -> T | Update[T]; setter -> None | T | Update[T] (value return = accepted/clamped readback, the sanctioned secop setpoint echo). Update[T] = value/timestamp/severity. Datatype optional when getter/setter given (inferred, unwrapping Update[T]). update_period: ONCE (default) / float / None (on-demand); no getter = @scan-fed soft. Access mode from which params exist; ReadIO trio dropped. Both prior open questions closed. ADR 0018: @attr is decorator-only (@attr / @attr(precision=3) + @x.setter); no @attr_r/@attr_rw, no free-function attr() factory; procedural is AttrR/AttrRW. Title + stale refs swept; 0013 refs swept too. demo README: five modules, four tutorials (the reusable-io= rung is gone); controllers.py repurposed to the composition/@scan/@command example folded into the declarative tutorial. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- ...-procedural-split-and-controller-filler.md | 4 +- .../decisions/0014-attribute-io-rw-rework.md | 60 +++++++++++-- .../decisions/0018-attr-decorator-sugar.md | 21 +++-- src/fastcs/demo/README.md | 87 +++++++++++-------- 4 files changed, 119 insertions(+), 53 deletions(-) diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md index 2de4e7d63..599cfe624 100644 --- a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -58,7 +58,7 @@ bare type hints only; instance scope = procedural construction.** Concretely: io=...)` may no longer be assigned directly in a class body. - Remove the deepcopy half of `_bind_attrs`. Method binding for `@command`/ `@scan` (the `UnboundCommand`/`UnboundScan` machinery) is unaffected and - stays, since it does not require deepcopy — see decision 14 (`@attr_rw` + stays, since it does not require deepcopy — see decision 14 (`@attr` decorator sugar). - Remove `HintedAttribute` and `_validate_type_hints`/`_validate_hinted_*` as a *separate* validation-only pass. Their job — "this hinted child must @@ -85,7 +85,7 @@ bare type hints only; instance scope = procedural construction.** Concretely: constructors (see [ADR 14](0014-attribute-io-rw-rework.md)). - The refined rule from decision 14 of #388: *class body = declarations + decorated behaviour; instance scope = construction with data.* This keeps - `@command`/`@scan`, and the new `@attr_r`/`@attr_rw` sugar, as class-body + `@command`/`@scan`, and the new `@attr`/`@x.setter` sugar, as class-body citizens, since none of them require per-instance deepcopy — they bind a method to `self` at construction time instead. diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 3bcb981cd..4077c536c 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -51,6 +51,52 @@ The dispatch-by-type registry has real costs in our downstream drivers: ## Decision +> **Review update (#402, 2026-07-22): `io=` objects replaced by `getter`/`setter` callables.** +> The `ReadIO`/`WriteIO`/`ReadWriteIO` hierarchy and the `io=` argument described +> below are **superseded.** Per-attribute IO is supplied as plain callables on the +> constructors, which *is* the procedural spelling of the `@attr` decorator +> ([ADR 18](0018-attr-decorator-sugar.md)): +> +> - `AttrR(getter=g)`, `AttrW(setter=s)`, `AttrRW(getter=g, setter=s)`. Access mode +> is enforced by **which parameters exist** (an `AttrR` has no `setter`), so the +> three-class IO hierarchy and its abstract-method enforcement are dropped +> entirely — and `getter=` on a read-only attr is honest where `io=` was a false +> friend. +> - **The getter returns the value; the framework applies it** — `getter() -> T | +> Update[T]` — instead of the old imperative `io.update(attr)`. Imperative / +> multi-attribute periodic logic stays with `@scan`, which is *why* per-attribute +> IO shrinks to "one value in / out". The **setter** returns `None | T | +> Update[T]`: `None` = fire-and-forget (readback catches up on the next poll / the +> setpoint cache); a returned value is the device's *accepted* value (clamp/echo) +> and updates the readback + `AttrW` setpoint cache immediately — the sanctioned +> replacement for `fastcs-secop`'s private `_call_sync_setpoint_callbacks`. +> - `Update[T]` = `value: T`, `timestamp: float | None` (epoch seconds; `None` ⇒ +> framework stamps receive-time), `severity: Severity = OK` (the decision-10b +> severity enum); used for both the getter return and a value-returning setter — +> this is how device-native timestamps/severity reach `attr.update()`. +> - **Datatype is optional when a getter/setter is given** — inferred from the +> getter's return annotation (or the setter's param), unwrapping `Update[T]` to +> `T`, so `AttrR(getter=g)` yields `AttrR[float]` with no restated type (parity +> with `@attr`). Only the bare python type is optional; `precision`/`units`/… stay +> explicit kwargs, and the per-datatype `Unpack[*Meta]` static check keys off the +> inferred return type. Not inferable (`-> Any`, unannotated lambda) ⇒ the +> positional datatype is required (fail-fast at construction). +> - `update_period` is a read-side kwarg: `ONCE` = read once at connect (the default +> when a getter is given); a float = poll at that rate; `None` = **on-demand only** +> (read when a client asks, never auto-polled). **No getter** = soft, value pushed +> via `attr.update()` from a `@scan`/callback. +> - Soft is now simply the *absence* of getter/setter (`AttrRW(float)` self-wires +> setpoint→readback as before); the `io=None` sentinel is gone. +> - The declarative/filler path lowers to the **same** getter/setter (a +> `SCPIController`'s filler builds the callables from `SCPIParam`); getter/setter +> are where the old `_connect_attribute_ios` wiring now lives, so transports and +> the embedded connector are unaffected. +> +> `attr` is a **decorator only** (`@attr` / `@attr(precision=3)` + `@my_attr.setter`); +> there is no free-function `attr()` factory — the procedural spelling is `AttrR`/ +> `AttrRW` directly. The `io=` prose below is kept for the `AttributeIORef`→callable +> migration context; read `getter=`/`setter=` for the final shape. + Replace `AttributeIO`/`AttributeIORef` with three focused, per-attribute IO base classes with abstract `update`/`send` methods, passed as a single `io=` constructor argument: @@ -241,10 +287,10 @@ a public method on `AttrW`/`ReadWriteIO` — exact shape is an open question. ## Open questions (awaiting input) -1. Final class names: `ReadIO`/`WriteIO`/`ReadWriteIO` vs. `AttrRIO`/`AttrWIO`/ - `AttrRWIO` vs. something else — see [ADR 17](0017-naming-pass.md). - *(awaiting @shihab-dls)* -2. Public replacement for `fastcs-secop`'s `_call_sync_setpoint_callbacks`: - an optional `sync_setpoint` argument on `WriteIO.send`, or a public method - on `AttrW` an IO author can call from `send`? *(awaiting @shihab-dls / - @Tom-Willemsen)* +Both original open questions are closed by the 2026-07-22 getter/setter model: + +1. ~~Final IO class names (`ReadIO`/`WriteIO`/`ReadWriteIO` vs …)~~ — **moot**: the + IO class hierarchy is gone; IO is plain `getter`/`setter` callables. +2. ~~Public replacement for `fastcs-secop`'s `_call_sync_setpoint_callbacks`~~ — + **resolved**: a `setter` returning `T | Update[T]` *is* the sanctioned setpoint + echo (updates readback + `AttrW` setpoint cache). diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index b4b7a6d07..87a1924cc 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -1,4 +1,4 @@ -# 18. Attr-from-Method Decorator Sugar (@attr_r / @attr_rw) +# 18. Attr-from-Method Decorator Sugar (`@attr` + `@x.setter`) Date: 2026-07-20 @@ -73,17 +73,20 @@ class PowerSupply(Controller): keyword the way PyTango does — decision 14 of #388 explicitly calls this out as *better* than PyTango's `dtype=` kwarg since it's one real annotation, checked statically. -- `@attr_r`/`@attr_rw` decorator keyword arguments (`units`, `update_period`, - etc.) map onto the equivalent `DataType`/`ReadIO`/`WriteIO` constructor - arguments from [ADR 14](0014-attribute-io-rw-rework.md) — this is sugar - over that mechanism, not a parallel one. +- `@attr` comes in two forms: bare `@attr` and parameterised `@attr(precision=3, + units="V", update_period=0.5)`; the keyword arguments map onto the same `*Meta` + fields and the `getter`/`setter` + `update_period` constructor arguments of + `AttrR`/`AttrRW` ([ADR 14](0014-attribute-io-rw-rework.md)) — sugar over that + mechanism, not a parallel one. There is **no** free-function `attr()` factory: + the procedural spelling is `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` + directly. - `@attr`'s `.setter` decorator mirrors `@property`/`@x.setter`, giving the read+write pair a single logical name (`voltage`) with two decorated methods. (`.send` is not used.) -- This degrades gracefully into the full `io=` object form for protocol - families with more complex needs, and into +- This degrades gracefully into the procedural `AttrR`/`AttrRW(getter=…, + setter=…)` form for protocol families with more complex needs, and into [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s - filler for introspection-driven attributes — `@attr_rw` is explicitly the + filler for introspection-driven attributes — `@attr` is explicitly the *simple* case, not a replacement for either. - Refines the class-body rule stated in [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) to: @@ -103,7 +106,7 @@ class PowerSupply(Controller): `CallbackReadIO`/`CallbackWriteIO`): `@attr` and `attr(getter=…)` are two spellings over one implementation. - Adds a third way to declare an attribute (bare hint + filler; explicit - `AttrRW(..., io=...)`; `@attr_rw` sugar) — the docs need to be clear about + `AttrRW(getter=…, setter=…)`; `@attr` sugar) — the docs need to be clear about when to reach for which, so this doesn't become three equally-weighted options with no guidance, undermining the "harsh split" clarity [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) is diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md index 9651ca596..110f518a7 100644 --- a/src/fastcs/demo/README.md +++ b/src/fastcs/demo/README.md @@ -8,40 +8,56 @@ mirror ophyd-async, so the examples install with `fastcs[demo]` and can be run, imported, and — crucially — used as the **single source of the tutorial code**. These modules are the canonical source the tutorials `literalinclude` from -(one tutorial per example, see the docs `tutorials/`). They are kept green -under `uv run --locked tox`, so the tutorials cannot drift from the framework: -every framework PR that changes an API updates the example(s) it affects in the -*same* PR. This replaces the old "hand-authored `docs/snippets/` that drift" -approach — the examples are the docs. - -## The five examples — a hello-world → complicated-device ladder - -Two hardware backends: a temperature-controller sim (steps 1–4) and a -cut-down Eiger REST sim (step 5). Step 1 is pure-soft (no backend). Each rung -introduces exactly one new concept. - -| # | Module | Concept | Backend | Issue | -|---|--------|---------|---------|-------| -| 1 | `hello_world.py` | pure-soft `@attr`/`@attr_rw` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | -| 2 | `temperature_attr.py` | callback getter/setter via the `attr` factory in `__init__` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404) | -| 3 | `controllers.py` | reusable per-attribute `io=` `ReadWriteIO` objects, sub-controllers/vectors, `@scan`/`@command` | temperature sim | [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | -| 4 | `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each `io` from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | -| 5 | `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | +(see the docs `tutorials/`). They are kept green under `uv run --locked tox`, +so the tutorials cannot drift from the framework: every framework PR that +changes an API updates the example(s) it affects in the *same* PR. This +replaces the old "hand-authored `docs/snippets/` that drift" approach — the +examples are the docs. + +## The example modules — a hello-world → complicated-device ladder + +Two hardware backends: a temperature-controller sim and a cut-down Eiger REST +sim. The hello-world is pure-soft (no backend). IO is supplied as plain +`getter`/`setter` callables on `AttrR`/`AttrW`/`AttrRW` (or the `@attr` +decorator) — there is no `io=` object and no `DataType`. + +| Module | Concept | Backend | Issue | +|--------|---------|---------|-------| +| `hello_world.py` | pure-soft `@attr` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | +| `temperature_attr.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`) | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404) | +| `controllers.py` | composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` (getter/setter IO) | temperature sim | [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | +| `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each getter/setter from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | +| `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | + +## The four tutorials + +Five modules, **four** tutorials (the old "reusable `io=` object" rung is gone — +`io=` objects were replaced by getter/setter callables, so there is nothing to +factor into): + +1. **hello world** — `hello_world.py` (soft `@attr`). +2. **getter/setter** — `temperature_attr.py`; closes with *"when the shared + pattern is worth naming, reach for the declarative style →"*. +3. **declarative** — `temperature_scpi.py` (annotated `SCPIParam` + filler), + and this is where **composition + `@scan` + `@command`** are shown, walking + the full multi-ramp temperature controller (`controllers.py`, #390). +4. **introspectable** — `eiger.py`. Notes: -- **Steps 2 vs 3** are the same device wired two ways — inline per-attribute - getter/setter, then the same IO factored into a reusable `io=` object — a - natural refactoring story on one backend. -- **Step 4 is deliberately *not* introspectable.** A SCPI device does not - describe itself, which is exactly why you hand-annotate: the metadata lives - in your Python (`SCPIParam("P", precision=3, …)`), not on the wire. Do **not** - invent SCPI introspection — that would erase the contrast with step 5. The - example `SCPIController`/`SCPIParam` vocabulary lives *here in the demo*, not - in core FastCS (decision 3: core ships no extras vocabulary for 1.0); it - demonstrates how a protocol layer builds on the filler's `(child, extras)` - mechanism. -- **Step 5 uses a separate Eiger REST backend on purpose.** Introspection earns +- **The declarative style is the DRY answer for a real protocol family**, not + a reusable IO object. Recommend it when the shared wire pattern is worth + naming (a protocol you'll reuse); for a handful of bespoke attributes, + getter/setter in `__init__` is lighter and fine. +- **`temperature_scpi.py` is deliberately *not* introspectable.** A SCPI device + does not describe itself, which is exactly why you hand-annotate: the metadata + lives in your Python (`SCPIParam("P", precision=3, …)`), not on the wire. Do + **not** invent SCPI introspection — that would erase the contrast with the + Eiger example. The `SCPIController`/`SCPIParam` vocabulary lives *here in the + demo*, not in core FastCS (decision 3: core ships no extras vocabulary for + 1.0); it demonstrates how a protocol layer builds on the filler's + `(child, extras)` mechanism. +- **`eiger.py` uses a separate REST backend on purpose.** Introspection earns its complexity only when a device's parameters aren't knowable at author time (a detector, not a fixed-command temp controller). The backend switch *is* the lesson — "small & known → declare; large & self-describing → introspect" @@ -51,10 +67,11 @@ Notes: ## Baselines vs framework PRs -Steps 2, 3, 5 have current-API baselines that can be written **now** -(deliberately messy against the pre-refactor API) and are cleaned up as each -framework PR lands. Steps 1 and 4 need framework work first (`attr` factory -#397; `ControllerFiller` #394). See each issue's `Blocked by:` line. +`temperature_attr.py`, `controllers.py`, and `eiger.py` have current-API +baselines that can be written **now** (deliberately messy against the +pre-refactor API) and are cleaned up as each framework PR lands. `hello_world.py` +and `temperature_scpi.py` need framework work first (`@attr` #397; +`ControllerFiller` #394). See each issue's `Blocked by:` line. `literalinclude` region markers are added to each module as part of writing its tutorial (the umbrella docs pass, From 3da025fa56a2a0594ca7697eb5ca10e9920c580c Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Wed, 22 Jul 2026 16:18:08 +0000 Subject: [PATCH 14/36] docs: ADR 14 - runtime surface (.readback/.setpoint, poll()/poll_period, set()) Rename/split the get()/update(value)/put(value) trio so access mode and device-IO are legible from the member set: - .value -> .readback + .setpoint (read-only properties; mirror bluesky/ ophyd Location(setpoint, readback); presence tracks access mode). - no-arg update() -> poll() (returns the value); update_period -> poll_period (schedule only). Deletes set_update_callback/bind_update_callback. - update(value) is now a pure cache push (no IO, no None sentinel). - put() -> set() (bluesky verb); caches .setpoint then runs setter; setter's T|Update[T] return feeds .readback. sync_setpoint kwarg gone. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- .../decisions/0014-attribute-io-rw-rework.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 4077c536c..551238154 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -97,6 +97,43 @@ The dispatch-by-type registry has real costs in our downstream drivers: > `AttrRW` directly. The `io=` prose below is kept for the `AttributeIORef`→callable > migration context; read `getter=`/`setter=` for the final shape. +### Runtime surface (review update, 2026-07-22) + +The `get()` / `update(value)` / `put(value)` method trio is renamed and split so +that both **access mode** and **whether a call touches the device** are legible +from the member set: + +| Member | Kind | AttrR | AttrW | AttrRW | Device IO? | +|---|---|---|---|---|---| +| `.readback` | property (sync) | ✓ | — | ✓ | no (cached) | +| `.setpoint` | property (sync) | — | ✓ | ✓ | no (cached) | +| `poll()` | async method | ✓ | — | ✓ | **yes** (getter) | +| `update(value)` | async method | ✓ | — | ✓ | no (cache push) | +| `set(value)` | async method | — | ✓ | ✓ | **yes** (setter) | + +- **`.readback` / `.setpoint` replace `.value`.** Two explicitly-named cached + properties instead of one whose meaning shifted per class. Each class exposes + only the ones it has (AttrR has no `.setpoint`, AttrW no `.readback`), so + access mode reads off the surface — and the pair mirrors bluesky / ophyd-async's + `Location(setpoint, readback)` exactly, so `AttrRW` maps 1:1 onto `locate()` + and the embedded connector's `get_value`/`get_setpoint`. Both are **read-only** + properties: writes are async (validate + `await` callbacks) and so cannot be + property setters. +- **`poll()` replaces the no-arg `update()`; `update_period` → `poll_period`.** + `poll()` does a live getter read, caches it, and **returns** the value (so an + on-demand read is `await attr.poll()`, mirroring ophyd's live `get_value()`); + `poll_period` (`ONCE` / float / `None`) is only the *schedule* the framework + calls it on. This deletes the `set_update_callback` / `bind_update_callback` + plumbing — the getter lives on the attr and `poll()` calls it. +- **`update(value)` is now purely a cache push** — a `value` or `Update[T]` from a + `@scan`/subscription — with no device IO and no `None` sentinel. +- **`set(value)` replaces `put()`** (the bluesky/ophyd verb): it caches + `.setpoint` immediately (decision 10a), then runs the setter; the setter's + `T | Update[T]` return feeds `.readback` via `update()`. The old + `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. + +So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do not. + Replace `AttributeIO`/`AttributeIORef` with three focused, per-attribute IO base classes with abstract `update`/`send` methods, passed as a single `io=` constructor argument: From 96cb4bc7e71849dcf3fe4ce9fd1f52b938efcabf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:13:42 +0000 Subject: [PATCH 15/36] demo: use ControllerVector for temperature ramp sub-controllers Evolve the demo temperature controller composition example onto the documented ControllerVector pattern instead of a manual list + add_sub_controller loop, and add unit tests exercising cancel_all and the voltage-distributing scan against a mocked IPConnection. Closes #390 --- src/fastcs/demo/controllers.py | 19 +++++------ tests/demo/test_controllers.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 tests/demo/test_controllers.py diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 5926fc8ce..3546fea20 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -8,7 +8,7 @@ from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controllers import Controller +from fastcs.controllers import Controller, ControllerVector from fastcs.datatypes import Enum, Float, Int, Waveform from fastcs.logging import logger from fastcs.methods import command, scan @@ -80,15 +80,16 @@ def __init__(self, settings: TemperatureControllerSettings) -> None: self._settings = settings - self._ramp_controllers: list[TemperatureRampController] = [] - for index in range(1, settings.num_ramp_controllers + 1): - controller = TemperatureRampController(index, self.connection) - self._ramp_controllers.append(controller) - self.add_sub_controller(f"R{index}", controller) + self.ramps: ControllerVector[TemperatureRampController] = ControllerVector( + { + index: TemperatureRampController(index, self.connection) + for index in range(1, settings.num_ramp_controllers + 1) + } + ) @command() async def cancel_all(self) -> None: - for rc in self._ramp_controllers: + for rc in self.ramps.values(): await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) @@ -118,14 +119,14 @@ async def update_voltages(self): await self.voltages.update(voltages) - for index, controller in enumerate(self._ramp_controllers): + for index, controller in self.ramps.items(): self.log_event( "Update voltages", topic=controller.voltage, query=query, response=voltages, ) - await controller.voltage.update(float(voltages[index])) + await controller.voltage.update(float(voltages[index - 1])) class TemperatureRampController(Controller): diff --git a/tests/demo/test_controllers.py b/tests/demo/test_controllers.py new file mode 100644 index 000000000..dd0adab82 --- /dev/null +++ b/tests/demo/test_controllers.py @@ -0,0 +1,59 @@ +from unittest.mock import AsyncMock + +import numpy as np +import pytest + +from fastcs.connections import IPConnectionSettings +from fastcs.controllers import ControllerVector +from fastcs.demo.controllers import ( + TemperatureController, + TemperatureControllerSettings, + TemperatureRampController, +) + + +@pytest.fixture +def controller() -> TemperatureController: + settings = TemperatureControllerSettings( + num_ramp_controllers=4, + ip_settings=IPConnectionSettings(ip="localhost", port=25565), + ) + controller = TemperatureController(settings) + controller.post_initialise() + return controller + + +def test_ramps_is_controller_vector(controller: TemperatureController): + assert isinstance(controller.ramps, ControllerVector) + assert list(controller.ramps) == [1, 2, 3, 4] + for index, ramp in controller.ramps.items(): + assert isinstance(ramp, TemperatureRampController) + assert controller.ramps[index] is ramp + + +@pytest.mark.asyncio +async def test_cancel_all_disables_every_ramp(controller: TemperatureController): + controller.connection.send_command = AsyncMock() # type: ignore[method-assign] + + await controller.cancel_all() + + sent_commands = [ + call.args[0] for call in controller.connection.send_command.call_args_list + ] + for index in controller.ramps: + assert f"N{index:02d}=0\r\n" in sent_commands + + +@pytest.mark.asyncio +async def test_update_voltages_updates_waveform_and_each_ramp( + controller: TemperatureController, +): + controller.connection.send_query = AsyncMock(return_value="[1, 2, 3, 4]\r\n") + + await controller.update_voltages() + + np.testing.assert_array_equal( + controller.voltages.get(), np.array([1, 2, 3, 4], dtype=np.int32) + ) + for index, ramp in controller.ramps.items(): + assert ramp.voltage.get() == pytest.approx(float(index)) From cf155075d2daec56cf7b86d72e68a2ccbabc6125 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:18:17 +0000 Subject: [PATCH 16/36] demo: cut-down Eiger REST sim + introspectable controller example Add a FastAPI fake REST sim (demo/simulation/eiger.py) shaped like a detector parameter tree (subsystems of named parameters, a keys listing endpoint, per-parameter GET/PUT), and an EigerDetector controller (demo/eiger.py) that type-hints half its attributes (checked via the current HintedAttribute mechanism) and fills the rest by introspecting the sim's keys endpoints in initialise(). Baseline uses the current API (AttrR/AttrRW + io_ref/AttributeIO); migrates to ControllerFiller when #394 lands. Closes #391 --- src/fastcs/demo/eiger.py | 139 ++++++++++++++++++++++++++++ src/fastcs/demo/simulation/eiger.py | 91 ++++++++++++++++++ tests/demo/test_eiger.py | 89 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/fastcs/demo/eiger.py create mode 100644 src/fastcs/demo/simulation/eiger.py create mode 100644 tests/demo/test_eiger.py diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py new file mode 100644 index 000000000..7584544e6 --- /dev/null +++ b/src/fastcs/demo/eiger.py @@ -0,0 +1,139 @@ +"""Example 5 - introspectable controller: a cut-down Eiger over the fake REST sim. + +Half the attributes (``count_time``, ``state``) are declared as type hints and +checked by the current ``HintedAttribute`` introspection-validation mechanism; the +rest of the parameter tree is discovered at ``initialise()`` time by walking the +sim's ``keys`` endpoints and is added dynamically, with no static check. A device +that describes itself over the wire is exactly the case where introspection earns +its complexity - contrast with the (deliberately non-introspectable) SCPI/temperature +examples. +""" + +from dataclasses import KW_ONLY, dataclass +from typing import Any + +import httpx + +from fastcs.attributes import AnyAttributeIO, AttributeIO, AttributeIORef, AttrR, AttrRW +from fastcs.controllers import Controller +from fastcs.datatypes import Bool, DataType, Float, Int, String +from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType +from fastcs.util import ONCE + +_DATATYPES: dict[ValueType, type[DataType]] = { + "float": Float, + "int": Int, + "string": String, + "bool": Bool, +} + + +@dataclass +class EigerConnectionSettings: + base_url: str = "http://localhost:8000" + + +class EigerConnection: + """Thin async HTTP client wrapper for the Eiger REST sim. + + A ``transport`` can be supplied to point directly at an in-process ASGI app + (e.g. in tests), bypassing the network entirely. + """ + + def __init__(self, transport: httpx.AsyncBaseTransport | None = None): + self._transport = transport + self._client: httpx.AsyncClient | None = None + + async def connect(self, settings: EigerConnectionSettings) -> None: + self._client = httpx.AsyncClient( + base_url=settings.base_url, transport=self._transport + ) + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("EigerConnection is not connected") + return self._client + + async def keys(self, subsystem: Subsystem) -> list[str]: + response = await self.client.get(f"{API_PREFIX}/{subsystem}/keys") + response.raise_for_status() + return response.json() + + async def get(self, subsystem: Subsystem, param: str) -> dict: + response = await self.client.get(f"{API_PREFIX}/{subsystem}/{param}") + response.raise_for_status() + return response.json() + + async def put(self, subsystem: Subsystem, param: str, value) -> None: + response = await self.client.put( + f"{API_PREFIX}/{subsystem}/{param}", json={"value": value} + ) + response.raise_for_status() + + +@dataclass +class EigerAttributeIORef(AttributeIORef): + subsystem: Subsystem + param: str + _: KW_ONLY + update_period: float | None = ONCE + + +class EigerAttributeIO(AttributeIO[Any, EigerAttributeIORef]): + def __init__(self, connection: EigerConnection): + super().__init__() + self._connection = connection + + async def update(self, attr: AttrR[Any, EigerAttributeIORef]) -> None: + data = await self._connection.get(attr.io_ref.subsystem, attr.io_ref.param) + await attr.update(attr.dtype(data["value"])) + + async def send(self, attr, value) -> None: + await self._connection.put(attr.io_ref.subsystem, attr.io_ref.param, value) + + +class EigerDetector(Controller): + """Cut-down Eiger controller: half declared, half introspected.""" + + # Declared (checked): must exist, with this access mode and dtype, after + # initialise() introspects the parameter tree. + count_time: AttrRW[float] + state: AttrR[str] + + def __init__( + self, + settings: EigerConnectionSettings | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.connection = EigerConnection(transport=transport) + ios: list[AnyAttributeIO] = [EigerAttributeIO(self.connection)] + super().__init__(ios=ios) + + self._settings = settings or EigerConnectionSettings() + + async def connect(self) -> None: + await self.connection.connect(self._settings) + self._connected = True + + async def disconnect(self) -> None: + await self.connection.close() + + async def initialise(self) -> None: + for subsystem in ("config", "status"): + for param in await self.connection.keys(subsystem): + data = await self.connection.get(subsystem, param) + datatype_cls = _DATATYPES[data["value_type"]] + io_ref = EigerAttributeIORef(subsystem=subsystem, param=param) + + if data["access_mode"] == "rw": + attr = AttrRW(datatype_cls(), io_ref=io_ref) + else: + attr = AttrR(datatype_cls(), io_ref=io_ref) + + self.add_attribute(param, attr) diff --git a/src/fastcs/demo/simulation/eiger.py b/src/fastcs/demo/simulation/eiger.py new file mode 100644 index 000000000..18282feb2 --- /dev/null +++ b/src/fastcs/demo/simulation/eiger.py @@ -0,0 +1,91 @@ +"""A cut-down, Eiger-shaped fake REST device for the introspectable controller demo. + +Mimics the shape of a real Eiger detector's parameter-tree REST API (subsystems of +named parameters, a ``keys`` listing endpoint, per-parameter GET/PUT) without any of +the real detector logic. Introspection earns its complexity only when a device's +parameters aren't knowable at author time - this sim exists to give that a genuine, +self-describing backend to introspect. +""" + +from dataclasses import dataclass +from typing import Any, Literal + +from fastapi import FastAPI, HTTPException + +ValueType = Literal["float", "int", "string", "bool"] +AccessMode = Literal["r", "rw"] +Subsystem = Literal["config", "status"] + +API_PREFIX = "/detector/api/1.8.0" + + +@dataclass +class EigerParameter: + value: Any + value_type: ValueType + access_mode: AccessMode = "r" + + +def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: + return { + "config": { + "count_time": EigerParameter(0.1, "float", "rw"), + "frame_time": EigerParameter(0.1, "float", "rw"), + "nimages": EigerParameter(1, "int", "rw"), + "description": EigerParameter("Simulated Eiger", "string", "r"), + }, + "status": { + "state": EigerParameter("idle", "string", "r"), + "temperature": EigerParameter(22.5, "float", "r"), + "humidity": EigerParameter(32.1, "float", "r"), + }, + } + + +def create_eiger_sim_app() -> FastAPI: + """Create a FastAPI app simulating a cut-down Eiger detector REST API.""" + app = FastAPI() + state = _initial_state() + + def _subsystem(subsystem: str) -> dict[str, EigerParameter]: + try: + return state[subsystem] # type: ignore[index] + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown subsystem '{subsystem}'" + ) from None + + def _parameter(subsystem: str, param: str) -> EigerParameter: + try: + return _subsystem(subsystem)[param] + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown parameter '{param}'" + ) from None + + @app.get(API_PREFIX + "/{subsystem}/keys") + async def get_keys(subsystem: str) -> list[str]: + return list(_subsystem(subsystem)) + + @app.get(API_PREFIX + "/{subsystem}/{param}") + async def get_parameter(subsystem: str, param: str) -> dict[str, Any]: + parameter = _parameter(subsystem, param) + return { + "value": parameter.value, + "value_type": parameter.value_type, + "access_mode": parameter.access_mode, + } + + @app.put(API_PREFIX + "/{subsystem}/{param}") + async def put_parameter( + subsystem: str, param: str, body: dict[str, Any] + ) -> dict[str, Any]: + parameter = _parameter(subsystem, param) + if parameter.access_mode != "rw": + raise HTTPException( + status_code=403, detail=f"Parameter '{param}' is read-only" + ) + parameter.value = body["value"] + return {"value": parameter.value} + + return app diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py new file mode 100644 index 000000000..f6b8142f8 --- /dev/null +++ b/tests/demo/test_eiger.py @@ -0,0 +1,89 @@ +import httpx +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient + +from fastcs.attributes import AttrR, AttrRW +from fastcs.demo.eiger import EigerDetector +from fastcs.demo.simulation.eiger import API_PREFIX, create_eiger_sim_app + + +@pytest.fixture +def sim_client() -> TestClient: + return TestClient(create_eiger_sim_app()) + + +def test_sim_lists_keys(sim_client: TestClient): + response = sim_client.get(f"{API_PREFIX}/config/keys") + assert response.status_code == 200 + assert set(response.json()) >= {"count_time", "frame_time", "nimages"} + + +def test_sim_get_parameter(sim_client: TestClient): + response = sim_client.get(f"{API_PREFIX}/config/count_time") + assert response.status_code == 200 + body = response.json() + assert body == {"value": 0.1, "value_type": "float", "access_mode": "rw"} + + +def test_sim_put_parameter(sim_client: TestClient): + response = sim_client.put(f"{API_PREFIX}/config/count_time", json={"value": 0.5}) + assert response.status_code == 200 + assert response.json() == {"value": 0.5} + + response = sim_client.get(f"{API_PREFIX}/config/count_time") + assert response.json()["value"] == 0.5 + + +def test_sim_put_read_only_parameter_rejected(sim_client: TestClient): + response = sim_client.put(f"{API_PREFIX}/status/state", json={"value": "busy"}) + assert response.status_code == 403 + + +def test_sim_unknown_parameter_404(sim_client: TestClient): + assert sim_client.get(f"{API_PREFIX}/config/nonexistent").status_code == 404 + assert sim_client.get(f"{API_PREFIX}/nonexistent/keys").status_code == 404 + + +@pytest_asyncio.fixture +async def detector() -> EigerDetector: + transport = httpx.ASGITransport(app=create_eiger_sim_app()) + controller = EigerDetector(transport=transport) + await controller.connect() + await controller.initialise() + controller.post_initialise() + return controller + + +@pytest.mark.asyncio +async def test_hinted_attributes_are_introspected(detector: EigerDetector): + assert isinstance(detector.count_time, AttrRW) + assert detector.count_time.datatype.dtype is float + + assert isinstance(detector.state, AttrR) + assert detector.state.datatype.dtype is str + + +@pytest.mark.asyncio +async def test_unhinted_attributes_are_also_introspected(detector: EigerDetector): + for name in ("frame_time", "nimages", "description", "temperature", "humidity"): + assert name in detector.attributes + + +@pytest.mark.asyncio +async def test_read_attribute_from_device(detector: EigerDetector): + await detector.count_time.bind_update_callback()() + assert detector.count_time.get() == 0.1 + + temperature = detector.attributes["temperature"] + assert isinstance(temperature, AttrR) + await temperature.bind_update_callback()() + assert temperature.get() == 22.5 + + +@pytest.mark.asyncio +async def test_write_attribute_to_device(detector: EigerDetector): + await detector.count_time.put(0.5) + + response = await detector.connection.get("config", "count_time") + assert response["value"] == 0.5 From c4cb0fca347333ce0f951f00ef5f097caf4c623b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:21:17 +0000 Subject: [PATCH 17/36] docs: ignore unresolvable httpx/fastapi type refs in nitpicky mode sphinx-build --fail-on-warning was erroring on autodoc cross-references to httpx.AsyncBaseTransport and fastapi.applications.FastAPI, which have no intersphinx mapping - same class of issue already worked around for p4p types. --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 3ee2ad966..99b82e5cd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,9 @@ ("py:class", "p4p.nt.enum.NTEnum"), ("py:class", "p4p.nt.ndarray.NTNDArray"), ("py:class", "p4p.nt.NTTable"), + # httpx and fastapi don't have intersphinx mappings + ("py:class", "httpx.AsyncBaseTransport"), + ("py:class", "fastapi.applications.FastAPI"), # Problems in FastCS itself ("py:class", "BaseController"), ("py:class", "AttrIOUpdateCallback"), From 54522d7581b526679c105f2bae8e2be2e4052101 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 23 Jul 2026 11:42:15 +0000 Subject: [PATCH 18/36] docs: fold @shihab-dls #402 replies into ADRs 0014 & 0019 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two review threads Shihab answered on 2026-07-22: - ADR 0019 (@scan): confirmed @scan is a purely-internal periodic coroutine bound to no Attr and surfacing no Signal; @command, by contrast, creates an AttrW and IS exposed. Closes the last open question on 0019; folded into the mapping table + Resolved-in-review. - ADR 0014 (setpoint echo): set() caching .setpoint is an attribute-cache guarantee only. Records Shihab's CA-vs-PVA divergence — PVA posts the setpoint immediately (may later alarm), CA posts only after the update callback completes, so a long-running setter delays the CA-visible setpoint. CA/PVA ordering realignment noted as a transport follow-up, not gating this rework. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- .../decisions/0014-attribute-io-rw-rework.md | 18 ++++++++++++++++- .../0019-embedded-ophyd-async-connector.md | 20 +++++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 551238154..92dfb4071 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -130,7 +130,10 @@ from the member set: - **`set(value)` replaces `put()`** (the bluesky/ophyd verb): it caches `.setpoint` immediately (decision 10a), then runs the setter; the setter's `T | Update[T]` return feeds `.readback` via `update()`. The old - `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. + `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. (Caching + `.setpoint` first is an *attribute-cache* guarantee; *when a remote client sees + it* is transport-dependent and differs between CA and PVA — see *Resolved in + review* below.) So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do not. @@ -321,6 +324,19 @@ a public method on `AttrW`/`ReadWriteIO` — exact shape is an open question. - **No `CallbackReadIO`/`CallbackWriteIO` in core.** The one-off callback case folds into the unified `attr` factory ([ADR 18](0018-attr-decorator-sugar.md)); the same decorator/factory covers the read-only and read/write cases. +- **Setpoint echo is an attribute-cache guarantee, not a transport one + (@Tom-Willemsen / @shihab-dls, #402):** `set()` caching `.setpoint` before it + runs the setter fixes the *framework*-level report that a setpoint PV didn't + reflect the just-written value, and is the sanctioned secop echo. Whether a + *remote client* sees that value immediately is transport-dependent, and the two + transports differ. **PVA** posts the setpoint as soon as it is written, then the + record may later go into alarm if the setter rejects it. **CA** posts the PV + update only *after* the update callback — where alarms are set — completes, so a + long-running setter delays the CA-visible setpoint until the send returns. This + means the `set()` semantics above are **not** a cross-transport "instantly + visible" guarantee. Realigning CA to PVA's post-before-send ordering (so GUIs + get immediate feedback on CA too) is a **transport-layer** follow-up, tracked + separately from this attribute-IO rework and not gating it. ## Open questions (awaiting input) diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md index 6bdd3ca87..92ba31c3f 100644 --- a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -108,7 +108,7 @@ Backend mappings (from #388 §5, grounded against the researched | `CommandBackend.execute`/`.signature` | `Command.__call__` / captured `Signature`, [ADR 15](0015-typed-commands.md) | | `SignalBackend.source` | e.g. `fastcs://.` | | child `Device` / `DeviceVector` | sub-`Controller` / `ControllerVector` | -| (not exposed) | `@scan` methods — server-side only, not surfaced to ophyd-async | +| (not exposed) | `@scan` methods — purely-internal periodic coroutines, bound to no `Attr`, not surfaced to ophyd-async (@shihab-dls, #402) | Datatype mapping: `Int`/`Float`/`Bool`/`String` → `int`/`float`/`bool`/`str`; `Waveform(array_dtype, shape)`/`Array1D` hint (per @@ -156,10 +156,14 @@ the enum class itself. Resolved in review (#402): - **Disconnect dropped:** reconnect is `Device.connect(force_reconnect=True)`; the only disconnect is `atexit`. No `Device.disconnect()` proposal (so #388 §8 item 8 / issue #401 is rewritten accordingly). - -## Open questions (awaiting input) - -1. Where does `@scan`-derived state that isn't exposed as a `Signal` go? All - attribute data already lives in `Attr` instances mapped to `Signal`s, so it - may be that `@scan` only drives updates and nothing extra needs surfacing — - needs confirming. *(awaiting @shihab-dls)* +- **`@scan` surfaces nothing; `@command` does (@shihab-dls, #402):** confirmed — + a `@scan`-decorated method is a **purely internal** coroutine run periodically; + it is *not* bound to an `Attr` and produces **no** `Signal`. All exposed state + already lives in `Attr` instances: a getter-based `AttrR` schedules its getter + as a scan-style task *and* is bound to the Attr (→ `Signal`), and a soft `AttrR` + fed by `@scan` via `update()` is likewise the exposed Signal — so `@scan` needs + nothing extra surfaced. A `@command` method **is** different: it creates an + `AttrW` and **is** exposed (the `CommandBackend` row above). Both `@scan` and + getter/update coroutines are collected onto the running loop in + `create_api_and_tasks()`; the connector schedules `@scan` coroutines as internal + tasks but never maps them to Signals. From 1932e5e22da15a3485a8f6250e4ebe8914e7e54f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:09:10 +0000 Subject: [PATCH 19/36] demo: drop redundant type hint on ramps assignment Address review comment: pyright already infers ControllerVector[TemperatureRampController] from the dict literal, so the explicit annotation was redundant. --- src/fastcs/demo/controllers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 3546fea20..b39937eee 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -80,7 +80,7 @@ def __init__(self, settings: TemperatureControllerSettings) -> None: self._settings = settings - self.ramps: ControllerVector[TemperatureRampController] = ControllerVector( + self.ramps = ControllerVector( { index: TemperatureRampController(index, self.connection) for index in range(1, settings.num_ramp_controllers + 1) From ff6292b0f9a00ee0998ab01f13ea4e82687bb336 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 23 Jul 2026 14:17:59 +0000 Subject: [PATCH 20/36] demo(#391): address review - idle derived attr, temp oscillation, poll read-only params - eiger.py: add soft `idle: AttrR[bool]` derived from the introspected `state` param (state == "idle"), kept in sync via an on-update callback - shows why we declare `state` as a checked attribute (to build code on top of it). - eiger.py: give read-only params a poll `update_period` in `initialise()`; rw params still read once (ONCE). - simulation/eiger.py: add a lifespan background task that sweeps `temperature` between two values so the front end shows something updating (real server only; the in-process ASGI transport used in tests stays deterministic). - tests: cover idle-from-state, read-only poll vs rw read-once, and oscillation. Co-Authored-By: Claude Opus 4.8 --- src/fastcs/demo/eiger.py | 21 +++++++++++++- src/fastcs/demo/simulation/eiger.py | 41 ++++++++++++++++++++++++++- tests/demo/test_eiger.py | 44 ++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py index 7584544e6..021dad7ca 100644 --- a/src/fastcs/demo/eiger.py +++ b/src/fastcs/demo/eiger.py @@ -27,6 +27,9 @@ "bool": Bool, } +# Poll period (seconds) for read-only status params that change on the device. +UPDATE_PERIOD = 0.2 + @dataclass class EigerConnectionSettings: @@ -106,6 +109,11 @@ class EigerDetector(Controller): count_time: AttrRW[float] state: AttrR[str] + # Derived (soft): built on top of the introspected ``state`` param. Declaring + # ``state`` as a checked attribute is what lets us reference it in code and + # publish something computed from it - here, whether the detector is idle. + idle = AttrR(Bool()) + def __init__( self, settings: EigerConnectionSettings | None = None, @@ -129,11 +137,22 @@ async def initialise(self) -> None: for param in await self.connection.keys(subsystem): data = await self.connection.get(subsystem, param) datatype_cls = _DATATYPES[data["value_type"]] - io_ref = EigerAttributeIORef(subsystem=subsystem, param=param) if data["access_mode"] == "rw": + io_ref = EigerAttributeIORef(subsystem=subsystem, param=param) attr = AttrRW(datatype_cls(), io_ref=io_ref) else: + # Read-only params are status values that change on the device, + # so poll them periodically rather than reading once. + io_ref = EigerAttributeIORef( + subsystem=subsystem, param=param, update_period=UPDATE_PERIOD + ) attr = AttrR(datatype_cls(), io_ref=io_ref) self.add_attribute(param, attr) + + # Keep the derived ``idle`` flag in sync with the introspected ``state``. + self.state.add_on_update_callback(self._update_idle) + + async def _update_idle(self, state: str) -> None: + await self.idle.update(state == "idle") diff --git a/src/fastcs/demo/simulation/eiger.py b/src/fastcs/demo/simulation/eiger.py index 18282feb2..000bf63e6 100644 --- a/src/fastcs/demo/simulation/eiger.py +++ b/src/fastcs/demo/simulation/eiger.py @@ -7,6 +7,11 @@ self-describing backend to introspect. """ +import asyncio +import math +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any, Literal @@ -42,11 +47,45 @@ def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: } +async def _oscillate_temperature( + parameter: EigerParameter, + low: float = 20.0, + high: float = 30.0, + period: float = 10.0, +) -> None: + """Slowly sweep a temperature parameter between two values, forever. + + Gives the front end something visibly changing to poll. Runs as a background + task under the app's lifespan (started by a real server, e.g. uvicorn; not by + the in-process ASGI transport used in tests, which keeps those deterministic). + """ + mid = (low + high) / 2 + amplitude = (high - low) / 2 + start = time.monotonic() + while True: + elapsed = time.monotonic() - start + parameter.value = round( + mid + amplitude * math.sin(2 * math.pi * elapsed / period), 1 + ) + await asyncio.sleep(0.1) + + def create_eiger_sim_app() -> FastAPI: """Create a FastAPI app simulating a cut-down Eiger detector REST API.""" - app = FastAPI() state = _initial_state() + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + task = asyncio.create_task( + _oscillate_temperature(state["status"]["temperature"]) + ) + try: + yield + finally: + task.cancel() + + app = FastAPI(lifespan=lifespan) + def _subsystem(subsystem: str) -> dict[str, EigerParameter]: try: return state[subsystem] # type: ignore[index] diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py index f6b8142f8..8f5e4b6e1 100644 --- a/tests/demo/test_eiger.py +++ b/tests/demo/test_eiger.py @@ -1,11 +1,14 @@ +import asyncio + import httpx import pytest import pytest_asyncio from fastapi.testclient import TestClient from fastcs.attributes import AttrR, AttrRW -from fastcs.demo.eiger import EigerDetector +from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector from fastcs.demo.simulation.eiger import API_PREFIX, create_eiger_sim_app +from fastcs.util import ONCE @pytest.fixture @@ -87,3 +90,42 @@ async def test_write_attribute_to_device(detector: EigerDetector): response = await detector.connection.get("config", "count_time") assert response["value"] == 0.5 + + +@pytest.mark.asyncio +async def test_idle_derived_from_state(detector: EigerDetector): + # ``idle`` is soft and starts at its default, tracking ``state`` once polled. + assert detector.idle.get() is False + + await detector.state.update("idle") + assert detector.idle.get() is True + + await detector.state.update("acquire") + assert detector.idle.get() is False + + +@pytest.mark.asyncio +async def test_read_only_params_poll_but_rw_read_once(detector: EigerDetector): + for name in ("state", "temperature", "humidity", "description"): + attr = detector.attributes[name] + assert isinstance(attr, AttrR) and not isinstance(attr, AttrRW) + assert attr.io_ref.update_period == UPDATE_PERIOD + + assert detector.count_time.io_ref.update_period is ONCE + + +@pytest.mark.asyncio +async def test_sim_temperature_oscillates(): + # The background task only runs under the app lifespan (a real server), not the + # bare ASGI transport used elsewhere, so drive the lifespan explicitly here. + app = create_eiger_sim_app() + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(base_url="http://sim", transport=transport) as c: + readings = [] + for _ in range(4): + await asyncio.sleep(0.3) + response = await c.get(f"{API_PREFIX}/status/temperature") + readings.append(response.json()["value"]) + + assert len(set(readings)) > 1, f"temperature did not change: {readings}" From ef0c3c6677b2e612b71f5387d0698abf08d3fa28 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 23 Jul 2026 14:39:06 +0000 Subject: [PATCH 21/36] docs: rewrite ADRs 0013-0019 with resolved-question tangle removed Fold the #402 review updates, "Resolved in review" statement dumps, and "Open questions" sections into one clean shape per ADR (Context / Decision / Consequences / Questions resolved in review). Make all seven consistent with the deleted io= and DataType: code examples are now all getter/setter + *Meta, and the runtime surface (.readback/.setpoint, poll()/poll_period, set()) is used uniformly across 0014/0016/0018/0019 instead of the old get()/put()/update_period/Waveform names. Preserves the @shihab-dls #402 replies (CA-vs-PVA setpoint visibility in 0014; @scan-vs-@command exposure in 0019), rewoven into the clean structure. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- ...-procedural-split-and-controller-filler.md | 93 +++-- .../decisions/0014-attribute-io-rw-rework.md | 369 ++++++++---------- .../decisions/0015-typed-commands.md | 46 +-- ...-cache-timestamps-and-controller-runner.md | 90 +++-- .../decisions/0017-naming-pass.md | 122 +++--- .../decisions/0018-attr-decorator-sugar.md | 151 +++---- .../0019-embedded-ophyd-async-connector.md | 90 +++-- 7 files changed, 492 insertions(+), 469 deletions(-) diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md index 599cfe624..1b802a5be 100644 --- a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -52,60 +52,69 @@ introspecting drivers use. ## Decision Adopt a single declarative mechanism, matching ophyd-async: **class body = -bare type hints only; instance scope = procedural construction.** Concretely: +declarations + decorated behaviour; instance scope = construction with data.** +Concretely: -- Remove class-scope `Attribute` **instances** entirely. `AttrRW(Float(), - io=...)` may no longer be assigned directly in a class body. +- Remove class-scope `Attribute` **instances** entirely. `AttrRW(getter=..., + setter=...)` may no longer be assigned directly in a class body. - Remove the deepcopy half of `_bind_attrs`. Method binding for `@command`/ - `@scan` (the `UnboundCommand`/`UnboundScan` machinery) is unaffected and - stays, since it does not require deepcopy — see decision 14 (`@attr` - decorator sugar). + `@scan` — and the new `@attr`/`@x.setter` sugar + ([ADR 18](0018-attr-decorator-sugar.md)) — is unaffected and stays, since it + does not require deepcopy: it binds a method to `self` at construction time + via the `UnboundCommand`/`UnboundScan` machinery rather than deepcopying a + prototype. - Remove `HintedAttribute` and `_validate_type_hints`/`_validate_hinted_*` as a *separate* validation-only pass. Their job — "this hinted child must exist with the right type after initialisation" — is subsumed into the new `ControllerFiller`. - Introduce `ControllerFiller`, a direct structural port of ophyd-async's `DeviceFiller`. It scans class-body type hints (`AttrR/W/RW[T]`, - `Command[P, T]` — see [ADR 15](0015-typed-commands.md), nested `Controller` - / `ControllerVector[T]`), creates children **unfilled**, and tracks - filled/unfilled state per child. `check_filled(source)` raises, listing by - name, anything a `Controller`'s `initialise()` promised via a hint but did - not provision. + `Command[P, T]` — see [ADR 15](0015-typed-commands.md) — and nested + `Controller` / `ControllerVector[T]`), creates children **unfilled**, and + tracks filled/unfilled state per child. `check_filled(source)` raises, + listing by name, anything a `Controller`'s `initialise()` promised via a hint + but did not provision. - `ControllerFiller` yields `(child, extras)` for each created child — the `extras` being anything else found in an `Annotated[...]` hint — so that protocol libraries (a future SCPI package, for example) can define their own extras vocabulary the same way ophyd-async's `PvSuffix`/`TangoPolling` do. Core FastCS defines **no** extras vocabulary for 1.0 (decision 3 of #388). -- When a child is filled from an `Annotated[Attr[T], extras]` hint, the filler - **runtime-validates** the metadata the extras carries (`FloatMeta`, or a +- When a child is filled from an `Annotated[AttrRW[T], extras]` hint, the filler + **runtime-validates** the metadata the extras carries (a `FloatMeta`, or a protocol object's `.meta` such as `SCPIParam(...).meta`) against the datatype `T` — e.g. `precision` supplied for a `str` raises. This is the runtime counterpart to the static `Unpack[FloatMeta]` check on the procedural `Attr*` constructors (see [ADR 14](0014-attribute-io-rw-rework.md)). -- The refined rule from decision 14 of #388: *class body = declarations + - decorated behaviour; instance scope = construction with data.* This keeps - `@command`/`@scan`, and the new `@attr`/`@x.setter` sugar, as class-body - citizens, since none of them require per-instance deepcopy — they bind a - method to `self` at construction time instead. -Two patterns follow, and the class body distinguishes them: +Two patterns follow, and the class body distinguishes them. **Procedural, no hint** — the value is fully constructed in `__init__`, so it -needs no class-body declaration at all (the temperature controller): +needs no class-body declaration at all. Per-attribute IO is a `getter`/`setter` +pair of callables ([ADR 14](0014-attribute-io-rw-rework.md)); the datatype is +inferred from the getter's return annotation: ```python class TemperatureRampController(Controller): def __init__(self, index: int, conn: IPConnection) -> None: super().__init__() suffix = f"{index:02d}" - self.start = AttrRW(Int(), io=TempIO(conn, "S", suffix)) + + async def get_start() -> int: + return int(await conn.send_query(f"S{suffix}?\r\n")) + + async def set_start(value: int) -> None: + await conn.send_command(f"S{suffix}={value}\r\n") + + # datatype int is inferred from get_start's return annotation + self.start = AttrRW(getter=get_start, setter=set_start, poll_period=0.2) ``` **Declarative hint + filler** — the value is *promised* by a hint; the `ControllerFiller` (run from `Controller.__init__`) creates it as an **unfilled** `Attribute` so it **exists as soon as `__init__` returns**, and -`initialise()` later *fills* it (provisions `io` + metadata) by introspection: +`initialise()` later *fills* it (provisions the getter/setter + metadata) by +introspection: ```python class OdinDetector(Controller): @@ -113,10 +122,10 @@ class OdinDetector(Controller): # self.frames EXISTS after __init__, before initialise() async def initialise(self) -> None: - # introspection FILLS the already-created hinted attrs (io + metadata), - # and may add wholly-undeclared dynamic attrs (which carry no hint) - for name, meta in await self._query_parameter_tree(): - self.filler.fill_attribute(name, ...) # validates meta vs datatype + # introspection FILLS the already-created hinted attrs (getter/setter + + # metadata), and may add wholly-undeclared dynamic attrs (no hint) + for name, spec in await self._query_parameter_tree(): + self.filler.fill_attribute(name, spec) # validates meta vs datatype self.filler.check_filled() ``` @@ -150,22 +159,24 @@ mirrors that `DeviceFiller` path directly. - `ControllerFiller` becomes a new stable, documented surface — see [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) for how it interacts with the stable `ControllerAPI` surface consumed by the - embedded ophyd-async connector. + embedded ophyd-async connector ([ADR 19](0019-embedded-ophyd-async-connector.md)). -## Resolved in review (#402) +## Questions resolved in review (#402) -1. **`ControllerFiller` must support "no hints at all"** — build the whole - attribute tree from introspected data (`fastcs-PandABlocks`, `fastcs-secop`). -2. **`fastcs-catio`'s runtime `type(...)` class-building is *not* supported.** - A bare `Controller` instead allows attributes to be added onto it from the +1. **Must `ControllerFiller` support "no hints at all"?** Yes — it must build + the whole attribute tree from introspected data with nothing declared in the + class body (`fastcs-PandABlocks`, `fastcs-secop`), exactly as + `DeviceFiller` does for a `PviConnector`. +2. **Is `fastcs-catio`'s runtime `type(...)` class-building supported?** No. A + bare `Controller` instead allows attributes to be added onto it from the outside — which is exactly what the fillers do — so catio moves to instance-level dynamic attribute construction. -3. **No sibling-ordering mechanism.** The rule "any hint-referenced Attribute - must exist by the end of `__init__`" makes `initialise()` parallelisable; - sibling dependencies are an `initialise()` implementation detail (call - `super().initialise()` first). -4. **`Optional[X]` hints are supported** — `check_filled` treats an optional - hint as not-required. -5. **Follow `DeviceFiller`'s structure, not its names.** Architectural - similarity matters; method names match only where FastCS's vocabulary - (`Attribute` vs `Signal`) makes them fit. +3. **Is there a sibling-ordering mechanism?** No. The rule "any hint-referenced + Attribute must exist by the end of `__init__`" makes `initialise()` + parallelisable; sibling dependencies are an `initialise()` implementation + detail (call `super().initialise()` first). +4. **Are `Optional[X]` hints supported?** Yes — `check_filled` treats an + optional hint as not-required. +5. **Do we follow `DeviceFiller`'s names?** Follow its *structure*, not its + names. Architectural similarity matters; method names match only where + FastCS's vocabulary (`Attribute` vs `Signal`) makes them fit. diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 92dfb4071..0b939687d 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -1,9 +1,10 @@ -# 14. AttributeIO R/W/RW Rework and Removal of AttributeIORef +# 14. Per-Attribute IO as getter/setter Callables Date: 2026-07-20 **Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), -[ADR 9](0009-handler-to-attribute-io-pattern.md), [ADR 12](0012-attribute-io-naming-convention.md) +[ADR 9](0009-handler-to-attribute-io-pattern.md), [ADR 12](0012-attribute-io-naming-convention.md), +[ADR 18](0018-attr-decorator-sugar.md) ## Status @@ -38,8 +39,8 @@ The dispatch-by-type registry has real costs in our downstream drivers: - `fastcs-catio` registers **three** separate `AttributeIO`/`AttributeIORef` pairs on one `Controller` (`ios=[poll_io, symbol_io, coe_io]`) purely so each attribute's `io_ref` type can select the right one at - `_connect_attribute_ios` time — indirection that a direct `io=` argument - removes outright. + `_connect_attribute_ios` time — indirection that a direct per-attribute + callable removes outright. - `fastcs-secop` needs a private escape hatch, `attr._call_sync_setpoint_callbacks`, to push setpoint echoes from its `send()` implementation, because the current `AttributeIO.send` signature @@ -51,57 +52,79 @@ The dispatch-by-type registry has real costs in our downstream drivers: ## Decision -> **Review update (#402, 2026-07-22): `io=` objects replaced by `getter`/`setter` callables.** -> The `ReadIO`/`WriteIO`/`ReadWriteIO` hierarchy and the `io=` argument described -> below are **superseded.** Per-attribute IO is supplied as plain callables on the -> constructors, which *is* the procedural spelling of the `@attr` decorator -> ([ADR 18](0018-attr-decorator-sugar.md)): -> -> - `AttrR(getter=g)`, `AttrW(setter=s)`, `AttrRW(getter=g, setter=s)`. Access mode -> is enforced by **which parameters exist** (an `AttrR` has no `setter`), so the -> three-class IO hierarchy and its abstract-method enforcement are dropped -> entirely — and `getter=` on a read-only attr is honest where `io=` was a false -> friend. -> - **The getter returns the value; the framework applies it** — `getter() -> T | -> Update[T]` — instead of the old imperative `io.update(attr)`. Imperative / -> multi-attribute periodic logic stays with `@scan`, which is *why* per-attribute -> IO shrinks to "one value in / out". The **setter** returns `None | T | -> Update[T]`: `None` = fire-and-forget (readback catches up on the next poll / the -> setpoint cache); a returned value is the device's *accepted* value (clamp/echo) -> and updates the readback + `AttrW` setpoint cache immediately — the sanctioned -> replacement for `fastcs-secop`'s private `_call_sync_setpoint_callbacks`. -> - `Update[T]` = `value: T`, `timestamp: float | None` (epoch seconds; `None` ⇒ -> framework stamps receive-time), `severity: Severity = OK` (the decision-10b -> severity enum); used for both the getter return and a value-returning setter — -> this is how device-native timestamps/severity reach `attr.update()`. -> - **Datatype is optional when a getter/setter is given** — inferred from the -> getter's return annotation (or the setter's param), unwrapping `Update[T]` to -> `T`, so `AttrR(getter=g)` yields `AttrR[float]` with no restated type (parity -> with `@attr`). Only the bare python type is optional; `precision`/`units`/… stay -> explicit kwargs, and the per-datatype `Unpack[*Meta]` static check keys off the -> inferred return type. Not inferable (`-> Any`, unannotated lambda) ⇒ the -> positional datatype is required (fail-fast at construction). -> - `update_period` is a read-side kwarg: `ONCE` = read once at connect (the default -> when a getter is given); a float = poll at that rate; `None` = **on-demand only** -> (read when a client asks, never auto-polled). **No getter** = soft, value pushed -> via `attr.update()` from a `@scan`/callback. -> - Soft is now simply the *absence* of getter/setter (`AttrRW(float)` self-wires -> setpoint→readback as before); the `io=None` sentinel is gone. -> - The declarative/filler path lowers to the **same** getter/setter (a -> `SCPIController`'s filler builds the callables from `SCPIParam`); getter/setter -> are where the old `_connect_attribute_ios` wiring now lives, so transports and -> the embedded connector are unaffected. -> -> `attr` is a **decorator only** (`@attr` / `@attr(precision=3)` + `@my_attr.setter`); -> there is no free-function `attr()` factory — the procedural spelling is `AttrR`/ -> `AttrRW` directly. The `io=` prose below is kept for the `AttributeIORef`→callable -> migration context; read `getter=`/`setter=` for the final shape. - -### Runtime surface (review update, 2026-07-22) - -The `get()` / `update(value)` / `put(value)` method trio is renamed and split so -that both **access mode** and **whether a call touches the device** are legible -from the member set: +Delete `AttributeIO` and `AttributeIORef` and their whole dispatch machinery. +Per-attribute IO is supplied as plain **`getter`/`setter` callables** on the +`Attr*` constructors — which *is* the procedural spelling of the `@attr` +decorator ([ADR 18](0018-attr-decorator-sugar.md)): + +- `AttrR(getter=g)`, `AttrW(setter=s)`, `AttrRW(getter=g, setter=s)`. Access + mode is enforced by **which parameters exist** (an `AttrR` has no `setter`), + so there is no IO class hierarchy and no abstract-method enforcement to + carry — and `getter=` on a read-only attr is honest where `io=` was a false + friend. +- **The getter returns the value; the framework applies it** — + `getter() -> T | Update[T]` — instead of the old imperative `io.update(attr)`. + Imperative / multi-attribute periodic logic stays with `@scan`, which is + *why* per-attribute IO shrinks to "one value in / out". +- The **setter** returns `None | T | Update[T]`: `None` = fire-and-forget + (readback catches up on the next poll / the setpoint cache); a returned value + is the device's *accepted* value (a clamp or echo) and updates the readback + + the `AttrW` setpoint cache immediately — the sanctioned replacement for + `fastcs-secop`'s private `_call_sync_setpoint_callbacks`. +- `Update[T]` carries `value: T`, `timestamp: float | None` (epoch seconds; + `None` ⇒ framework stamps receive-time), and `severity: Severity = OK` (the + decision-10b severity enum, see + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). It is + used for both the getter return and a value-returning setter — this is how + device-native timestamps/severity reach `attr.update()`. +- **Datatype is optional when a getter/setter is given** — inferred from the + getter's return annotation (or the setter's parameter), unwrapping `Update[T]` + to `T`, so `AttrR(getter=g)` yields `AttrR[float]` with no restated type + (parity with `@attr`). Only the bare python type is optional; + `precision`/`units`/… stay explicit kwargs, and the per-datatype + `Unpack[*Meta]` static check keys off the inferred return type. Not inferable + (`-> Any`, an unannotated lambda) ⇒ the positional datatype is required + (fail-fast at construction). +- `poll_period` is a read-side kwarg: `ONCE` = read once at connect (the + default when a getter is given); a float = poll at that rate; `None` = + **on-demand only** (read when a client asks, never auto-polled). **No + getter** = soft, value pushed via `attr.update()` from a `@scan`/callback. +- Soft is now simply the *absence* of a getter/setter (`AttrRW(float)` + self-wires setpoint→readback as before, the analogue of ophyd-async's + `soft_signal_rw`); the old `io=None` sentinel is gone. +- The declarative/filler path lowers to the **same** getter/setter (a + `SCPIController`'s filler builds the callables from a `SCPIParam`). getter and + setter are where the old `_connect_attribute_ios` wiring now lives, so + transports and the embedded connector are unaffected. + +`attr` is a **decorator only** (`@attr` / `@attr(precision=3)` + +`@voltage.setter`, [ADR 18](0018-attr-decorator-sugar.md)); there is no +free-function `attr()` factory — the procedural spelling is `AttrR`/`AttrRW` +directly. + +```python +class TemperatureRampController(Controller): + def __init__(self, index: int, conn: IPConnection) -> None: + super().__init__() + name = f"R{index:02d}" + + async def get_ramp_rate() -> float: + return float(await conn.send_query(f"{name}?\r\n")) + + async def set_ramp_rate(value: float) -> None: + await conn.send_command(f"{name}={value}\r\n") + + # datatype float inferred from get_ramp_rate's return annotation + self.ramp_rate = AttrRW( + getter=get_ramp_rate, setter=set_ramp_rate, units="deg", poll_period=0.2 + ) +``` + +### Runtime surface + +The old `get()` / `update(value)` / `put(value)` method trio is renamed and +split so that both **access mode** and **whether a call touches the device** +are legible from the member set: | Member | Kind | AttrR | AttrW | AttrRW | Device IO? | |---|---|---|---|---|---| @@ -113,91 +136,41 @@ from the member set: - **`.readback` / `.setpoint` replace `.value`.** Two explicitly-named cached properties instead of one whose meaning shifted per class. Each class exposes - only the ones it has (AttrR has no `.setpoint`, AttrW no `.readback`), so - access mode reads off the surface — and the pair mirrors bluesky / ophyd-async's - `Location(setpoint, readback)` exactly, so `AttrRW` maps 1:1 onto `locate()` - and the embedded connector's `get_value`/`get_setpoint`. Both are **read-only** - properties: writes are async (validate + `await` callbacks) and so cannot be - property setters. + only the ones it has (`AttrR` has no `.setpoint`, `AttrW` no `.readback`), so + access mode reads off the surface — and the pair mirrors bluesky / + ophyd-async's `Location(setpoint, readback)` exactly, so `AttrRW` maps 1:1 + onto `locate()` and the embedded connector's `get_value`/`get_setpoint`. Both + are **read-only** properties: writes are async (validate + `await` callbacks) + and so cannot be property setters. - **`poll()` replaces the no-arg `update()`; `update_period` → `poll_period`.** `poll()` does a live getter read, caches it, and **returns** the value (so an on-demand read is `await attr.poll()`, mirroring ophyd's live `get_value()`); `poll_period` (`ONCE` / float / `None`) is only the *schedule* the framework calls it on. This deletes the `set_update_callback` / `bind_update_callback` plumbing — the getter lives on the attr and `poll()` calls it. -- **`update(value)` is now purely a cache push** — a `value` or `Update[T]` from a - `@scan`/subscription — with no device IO and no `None` sentinel. +- **`update(value)` is now purely a cache push** — a `value` or `Update[T]` + from a `@scan`/subscription — with no device IO and no `None` sentinel. - **`set(value)` replaces `put()`** (the bluesky/ophyd verb): it caches `.setpoint` immediately (decision 10a), then runs the setter; the setter's `T | Update[T]` return feeds `.readback` via `update()`. The old - `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. (Caching - `.setpoint` first is an *attribute-cache* guarantee; *when a remote client sees - it* is transport-dependent and differs between CA and PVA — see *Resolved in - review* below.) - -So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do not. + `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. Caching + `.setpoint` first is an *attribute-cache* guarantee only; *when a remote + client sees it* is transport-dependent and differs between CA and PVA — see + the Questions resolved below. -Replace `AttributeIO`/`AttributeIORef` with three focused, per-attribute IO -base classes with abstract `update`/`send` methods, passed as a single `io=` -constructor argument: - -```python -class ReadIO(Generic[DType_T], ABC): - def __init__(self, update_period: float | None = None): ... - - @abstractmethod - async def update(self, attr: AttrR[DType_T]) -> None: ... - - -class WriteIO(Generic[DType_T], ABC): - @abstractmethod - async def send(self, attr: AttrW[DType_T], value: DType_T) -> None: ... - - -class ReadWriteIO(ReadIO[DType_T], WriteIO[DType_T], ABC): ... -``` - -(Working names per #388; exact naming — `ReadIO`/`WriteIO`/`ReadWriteIO` vs. -`AttrRIO`/`AttrWIO`/`AttrRWIO` — is an open question below and in -[ADR 17](0017-naming-pass.md).) Concrete IO classes are **dataclasses**, so -per-attribute fields (register name, command string, `update_period`) are -declared without a boilerplate `__init__`. - -- `AttrR(dt, io: ReadIO[DType_T] | None)`, `AttrW(dt, io: WriteIO[DType_T] | - None)`, `AttrRW(dt, io: ReadWriteIO[DType_T] | None)`. Passing a - read-only IO to an `AttrRW` is a **static** type error, not a runtime - `_validate_io` check — the abstract methods force a subclass to implement - the right surface for the `Attr` flavour it is attached to. -- `update_period` moves onto `ReadIO` — it describes the IO's polling - behaviour, not a property of the attribute. `Controller.create_api_and_tasks` - schedules from `attr.io.update_period` instead of pattern-matching on - `AttributeIORef` (`control_system.py`/`controller.py`'s - `case AttrR(_io_ref=AttributeIORef(update_period=update_period))` becomes a - direct attribute access). -- **Delete:** `AttributeIORef`, the `ios=` constructor kwarg on - `BaseController`/`Controller`/`ControllerVector`, `_validate_io`, - `_connect_attribute_ios`, `_attribute_ref_io_map`, `__init_subclass__`'s - generic-arg sniffing in `AttributeIO`, and the second TypeVar — - `Attribute[DType_T, AttributeIORefT]` collapses to `Attribute[DType_T]`, - making `AttrRW[float]` structurally isomorphic to ophyd-async's - `SignalRW[float]`. -- `io=None` keeps today's soft-attribute behaviour: `AttrRW` self-wires - setpoint→readback via `_internal_update`, and the sync-setpoint machinery - is unaffected. This remains the analogue of ophyd-async's - `soft_signal_rw`. -- The one-off "no subclass needed" case is served by the unified `attr` - factory (see [ADR 18](0018-attr-decorator-sugar.md)) — `self.x = - attr(getter=cb)` / `@attr` — **not** by separate `CallbackReadIO`/ - `CallbackWriteIO` classes. The same `attr` covers the read-only and - read/write callback cases, so the two adapters are not shipped. +So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do +not. `Attribute` also loses its second generic parameter — +`Attribute[DType_T, AttributeIORefT]` collapses to `Attribute[DType_T]`, making +`AttrRW[float]` structurally isomorphic to ophyd-async's `SignalRW[float]`. ### Datatype metadata: the `*Meta` TypedDicts -`DataType` classes are gone (ADR 15/17). The metadata they carried (precision, -units, nested limits, …) moves to a per-datatype `TypedDict` — `FloatMeta`, -`IntMeta`, `StrMeta`, `BoolMeta`, `EnumMeta`, `Array1DMeta`, `TableMeta` — and -**the resolved metadata is stored on the `Attribute` itself** (`attr.meta`), -not on a separate datatype object. Every transport/connector that read +`DataType` classes are gone ([ADR 15](0015-typed-commands.md) / +[ADR 17](0017-naming-pass.md)). The metadata they carried (precision, units, +nested limits, …) moves to a per-datatype `TypedDict` — `FloatMeta`, `IntMeta`, +`StrMeta`, `BoolMeta`, `EnumMeta`, `Array1DMeta`, `TableMeta` — and **the +resolved metadata is stored on the `Attribute` itself** (`attr.meta`), not on a +separate datatype object. Every transport/connector that read `attr.datatype.precision`/`.units`/`.limits`/`.choices` now reads `attr.meta` (enum `choices` come from the python type; `EnumMeta` is display-only). @@ -208,12 +181,16 @@ Two spellings, two validation layers: ```python # conceptually, one overload per datatype: - def AttrRW(dtype: type[float], *, io=..., **kwargs: Unpack[FloatMeta]) -> AttrRW[float]: ... + def AttrRW(dtype: type[float], *, getter=..., setter=..., + **kwargs: Unpack[FloatMeta]) -> AttrRW[float]: ... - self.temperature = AttrRW(float, precision=3, units="deg", io=TempIO(...)) + self.temperature = AttrRW(float, precision=3, units="deg", setter=apply_temp) # AttrRW(str, precision=3) is a static type error ``` + (The `dtype` positional is only needed when it cannot be inferred from a + getter/setter annotation, as above.) + - **Declarative (runtime-checked by the filler):** `Annotated[AttrRW[float], FloatMeta(precision=3)]` (rare) or `Annotated[AttrRW[float], SCPIParam("P", precision=3)]` (common). Neither @@ -250,14 +227,14 @@ of Python object. `SCPIParam` is a sibling of ophyd-async's FastCS (decision 3: core defines no extras vocabulary for 1.0) — it lives in a protocol layer; the demo package ships an example `SCPIController` + `SCPIParam` to show how a third party builds one on the filler's -`(child, extras)` mechanism. +`(child, extras)` mechanism. The `*Meta` module location is deferred to the +public-API-namespace decision (#406); land it provisionally until then. -The `*Meta` module location is deferred to the public-API-namespace decision -(#406); land it provisionally until then. +### Migration -Migration is mechanical for the common case (an old `AttributeIO` subclass -absorbs its `AttributeIORef`'s fields into its own `__init__` and is -constructed once per attribute instead of once per controller): +Migration collapses an `AttributeIO`/`AttributeIORef` pair into two callables: +the old `update`/`send` method bodies become the `getter`/`setter`, constructed +once per attribute instead of once per controller. ```python # Before (ADR 9 shape) @@ -269,81 +246,75 @@ class TempIO(AttributeIO[float, TempIORef]): resp = await self._conn.send_query(f"{attr.io_ref.name}?\r\n") await attr.update(float(resp)) + async def send(self, attr: AttrW[float, TempIORef], value: float) -> None: + await self._conn.send_command(f"{attr.io_ref.name}={value}\r\n") + ramp_rate = AttrRW(Float(), io_ref=TempIORef(name="R")) # ... elsewhere: Controller(ios=[TempIO(conn)]) # After -class TempIO(ReadWriteIO[float]): - def __init__(self, conn: IPConnection, name: str, update_period=0.2): - super().__init__(update_period=update_period) - self._conn, self._name = conn, name +def temp_io(conn: IPConnection, name: str): + async def getter() -> float: + return float(await conn.send_query(f"{name}?\r\n")) - async def update(self, attr: AttrR[float]) -> None: - resp = await self._conn.send_query(f"{self._name}?\r\n") - await attr.update(float(resp)) + async def setter(value: float) -> None: + await conn.send_command(f"{name}={value}\r\n") - async def send(self, attr: AttrW[float], value: float) -> None: - await self._conn.send_command(f"{self._name}={value}\r\n") + return getter, setter -self.ramp_rate = AttrRW(Float(), io=TempIO(conn, "R")) +get_ramp, set_ramp = temp_io(conn, "R") +self.ramp_rate = AttrRW(getter=get_ramp, setter=set_ramp, poll_period=0.2) ``` -`fastcs-catio`'s three-IO-per-controller pattern becomes three IO -*instances*, one per relevant attribute, with no registry needed at all. -`fastcs-secop`'s private `_call_sync_setpoint_callbacks` call is replaced by -a public method on `AttrW`/`ReadWriteIO` — exact shape is an open question. +`fastcs-catio`'s three-IO-per-controller pattern becomes per-attribute +callables with no registry needed at all. `fastcs-secop`'s private +`_call_sync_setpoint_callbacks` call is replaced by a value-returning setter. ## Consequences -- Every driver that declared `AttributeIORef` subclasses must migrate them - into `AttributeIO.__init__` fields — see the affected §9 files in the - sub-issues of #388 (`attributes/`, `controllers/base_controller.py`, - `controllers/controller.py`) and the corresponding downstream repo issues. +- Every driver that declared `AttributeIO`/`AttributeIORef` subclasses migrates + their `update`/`send` bodies into `getter`/`setter` callables — see the + affected §9 files in the sub-issues of #388 (`attributes/`, + `controllers/base_controller.py`, `controllers/controller.py`) and the + corresponding downstream repo issues. The migration is mechanical. - `Attribute` loses its second generic parameter, simplifying every type hint in downstream code (`AttrR[float, MyRef]` → `AttrR[float]`). -- Access-mode compatibility between an `Attr` and its `io=` argument is - caught by the type checker instead of at runtime in `_validate_io` — - earlier feedback for driver authors, at the cost of losing the runtime - "no AttributeIO registered for this ref type" error message; a - misconfigured `io=None` on an attribute that needed IO now simply behaves - as a soft attribute rather than raising loudly. Whether this needs a - runtime check as well (e.g. in `post_initialise`) is an open question. -- [ADR 12](0012-attribute-io-naming-convention.md)'s guidance (subclass to - get a shorter driver-local name) still applies to the new `ReadIO`/ - `WriteIO`/`ReadWriteIO` names. - -## Resolved in review (#402) - -- **Runtime check: yes.** Alongside the static type error, a runtime check - (e.g. at `post_initialise`) catches a read-only IO on a write-capable `Attr` - for the dynamically-built `Any`-typed case (`fastcs-secop`, - `fastcs-PandABlocks`). -- **`attr.io` becomes a public, typed property** — the sanctioned way to - recover an attribute's IO-specific metadata from *outside* its `send`/ - `update` (replaces `fastcs-catio`'s `attribute.io_ref` access). -- **No `CallbackReadIO`/`CallbackWriteIO` in core.** The one-off callback case - folds into the unified `attr` factory ([ADR 18](0018-attr-decorator-sugar.md)); - the same decorator/factory covers the read-only and read/write cases. -- **Setpoint echo is an attribute-cache guarantee, not a transport one - (@Tom-Willemsen / @shihab-dls, #402):** `set()` caching `.setpoint` before it - runs the setter fixes the *framework*-level report that a setpoint PV didn't - reflect the just-written value, and is the sanctioned secop echo. Whether a - *remote client* sees that value immediately is transport-dependent, and the two - transports differ. **PVA** posts the setpoint as soon as it is written, then the - record may later go into alarm if the setter rejects it. **CA** posts the PV - update only *after* the update callback — where alarms are set — completes, so a - long-running setter delays the CA-visible setpoint until the send returns. This - means the `set()` semantics above are **not** a cross-transport "instantly - visible" guarantee. Realigning CA to PVA's post-before-send ordering (so GUIs - get immediate feedback on CA too) is a **transport-layer** follow-up, tracked - separately from this attribute-IO rework and not gating it. - -## Open questions (awaiting input) - -Both original open questions are closed by the 2026-07-22 getter/setter model: - -1. ~~Final IO class names (`ReadIO`/`WriteIO`/`ReadWriteIO` vs …)~~ — **moot**: the - IO class hierarchy is gone; IO is plain `getter`/`setter` callables. -2. ~~Public replacement for `fastcs-secop`'s `_call_sync_setpoint_callbacks`~~ — - **resolved**: a `setter` returning `T | Update[T]` *is* the sanctioned setpoint - echo (updates readback + `AttrW` setpoint cache). +- Access-mode compatibility is enforced by the parameter set — an `AttrR` has + no `setter`, so there is no `_validate_io` runtime check and no way to attach + a read-only IO to a write-capable attr statically. For the dynamically-built + `Any`-typed case (`fastcs-secop`, `fastcs-PandABlocks`) a runtime check at + `post_initialise` still catches a missing setter on a write-capable attr. +- The IO no longer has a place to hang per-attribute metadata that + `fastcs-catio` used to read off `attribute.io_ref`; `attr.meta` and the + attribute's own attributes replace that access. + +## Questions resolved in review (#402) + +1. **What replaces the `io=` object and the `ReadIO`/`WriteIO`/`ReadWriteIO` + hierarchy?** Plain `getter`/`setter` callables on the constructors. The IO + class hierarchy and its abstract-method enforcement are dropped entirely; + access mode is enforced by which parameters exist. +2. **Do we still need a runtime access-mode check?** Yes, in addition to the + static shape: a runtime check (e.g. at `post_initialise`) catches a missing + setter on a write-capable `Attr` for the dynamically-built `Any`-typed case. +3. **What is the public replacement for `fastcs-secop`'s + `_call_sync_setpoint_callbacks`?** A `setter` returning `T | Update[T]` *is* + the sanctioned setpoint echo — the returned value updates the readback and + the `AttrW` setpoint cache. +4. **Are there `CallbackReadIO`/`CallbackWriteIO` classes in core?** No. The + one-off callback case folds into `@attr` / `AttrR(getter=…)` + ([ADR 18](0018-attr-decorator-sugar.md)); the same spelling covers the + read-only and read/write cases. +5. **How is per-attribute IO metadata recovered from outside `getter`/`setter`?** + Through `attr.meta` and the attribute's own public members, replacing + `fastcs-catio`'s `attribute.io_ref` access. +6. **Is the setpoint echo a cross-transport "instantly visible" guarantee?** + (@Tom-Willemsen / @shihab-dls.) No — caching `.setpoint` before running the + setter is an *attribute-cache* guarantee (and the sanctioned secop echo); + whether a *remote client* sees it immediately is transport-dependent. **PVA** + posts the setpoint as soon as it is written, then the record may later go + into alarm if the setter rejects it. **CA** posts the PV update only *after* + the update callback (where alarms are set) completes, so a long-running + setter delays the CA-visible setpoint until the send returns. Realigning CA + to PVA's post-before-send ordering is a **transport-layer** follow-up, + tracked separately and not gating this rework. diff --git a/docs/explanations/decisions/0015-typed-commands.md b/docs/explanations/decisions/0015-typed-commands.md index f4d488da7..e59f9017d 100644 --- a/docs/explanations/decisions/0015-typed-commands.md +++ b/docs/explanations/decisions/0015-typed-commands.md @@ -106,25 +106,27 @@ need a `TYPE_CHECKING` stub trick and are prototyped separately in the spike `*Meta` mechanism as attributes (the `DataType` family is removed, ADR 17) — one shared validation/serialisation path, no command-specific duplicate. -## Resolved in review (#402) - -- **Validation shares the attribute path.** With `DataType` dropped (ADR 17), - command args/returns use python types + `*Meta` like attributes; no separate - mechanism, and complex-type serialisation (arrays, `Enum`, `Table`) is shared - with `Attribute`. -- **Args and returns are typed independently** — Args `[]` / `[DT…]`; - Returns `None` / `DT` (see Decision). Not all-or-nothing, but each is fully - known — there is no `Any` middle case. -- **No partial `Command[Any, Any]`.** @Tom-Willemsen confirmed on - [#402](https://github.com/DiamondLightSource/fastcs/pull/402#discussion_r3621453680) - that SECoP devices are discovered entirely from an over-the-wire `describe`: - you never statically know something is a command but not its signature — you - either know the full `Command[P, T]` or you know nothing at all and build the - whole controller at runtime. `P`/`T` are therefore completely known or the - whole structure is unknown, with no in-between. -- **EPICS skip-with-warning fires at IOC startup** — post controller - construction, when the fully populated controllers are handed to the - transports to serve. -- **Keyword-arg commands → spike [#403](https://github.com/DiamondLightSource/fastcs/issues/403)** - (interactive/Opus; needs a `TYPE_CHECKING` stub). Out of scope for core - typed-command work. +## Questions resolved in review (#402) + +1. **Do commands need their own type/serialisation mechanism?** No — they share + the attribute path. With `DataType` dropped ([ADR 17](0017-naming-pass.md)), + command args/returns use python types + `*Meta` like attributes, and + complex-type serialisation (arrays, `Enum`, `Table`) is shared with + `Attribute`. +2. **Are args and returns typed all-or-nothing?** No — independently: args `[]` + / `[DT…]`; returns `None` / `DT` (see Decision). Each is fully known — there + is no `Any` middle case. +3. **Is there a partial `Command[Any, Any]`?** No. @Tom-Willemsen confirmed on + [#402](https://github.com/DiamondLightSource/fastcs/pull/402#discussion_r3621453680) + that SECoP devices are discovered entirely from an over-the-wire `describe`: + you never statically know something is a command but not its signature — you + either know the full `Command[P, T]` or you know nothing at all and build the + whole controller at runtime. `P`/`T` are therefore completely known or the + whole structure is unknown, with no in-between. +4. **When does the EPICS skip-with-warning fire?** At IOC startup — post + controller construction, when the fully populated controllers are handed to + the transports to serve. +5. **What about keyword-argument commands?** Deferred to spike + [#403](https://github.com/DiamondLightSource/fastcs/issues/403) + (interactive/Opus; needs a `TYPE_CHECKING` stub). Out of scope for core + typed-command work. diff --git a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md index 5148edf4c..4f67b0528 100644 --- a/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md +++ b/docs/explanations/decisions/0016-setpoint-cache-timestamps-and-controller-runner.md @@ -2,7 +2,8 @@ Date: 2026-07-20 -**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 14](0014-attribute-io-rw-rework.md) ## Status @@ -14,12 +15,12 @@ Three related gaps block a clean embedded ophyd-async connector (see [ADR 19](0019-embedded-ophyd-async-connector.md)) and are useful to all transports independently of embedding: -1. **No cached setpoint.** `AttrW.put` (`src/fastcs/attributes/attr_w.py`) - applies a setpoint via `_on_put_callback` but does not retain it anywhere +1. **No cached setpoint.** The old `AttrW.put` (`src/fastcs/attributes/attr_w.py`) + applied a setpoint via `_on_put_callback` but did not retain it anywhere queryable. ophyd-async's `SignalBackend.get_setpoint()` — needed for `locate()` — has no FastCS equivalent to read from. -2. **No FastCS-native timestamps.** `AttrR.update` (`attr_r.py`) stamps - nothing; individual transports each do their own thing (EPICS records +2. **No FastCS-native timestamps.** The old `AttrR.update` (`attr_r.py`) stamped + nothing; individual transports each did their own thing (EPICS records get a timestamp from the record subsystem, Tango pushes are unstamped). An embedded connector currently has no choice but to stamp receive-time only, which is a real information loss versus what the underlying device @@ -40,24 +41,29 @@ to using — no reaching into `BaseController` internals. ## Decision -**Setpoint cache:** `AttrW` gains an internally-tracked last-applied -setpoint, exposed via a public `AttrW.get_setpoint()` method (mirroring -ophyd-async's `SignalBackend.get_setpoint()`), updated whenever `put` is -called, independent of whether the underlying `send` succeeds. This is available to all transports (not just the embedded -connector) as a "what did we last ask for" query distinct from `AttrR.get()` -("what did we last read back"). - -**Native timestamps (+ severity):** `AttrR.update` accepts an optional -timestamp (and, where meaningful, severity) alongside the value, defaulting -to current time if not supplied by the caller. The timestamp/severity pair -**follows bluesky's `Reading` shape but shares no code** with it, and severity -is a **FastCS enum using the same strings as EPICS** alarm severities. This is -FastCS-native, not EPICS-specific — Tango event pushes and other IO can supply a device-side -timestamp through the same path a `ReadIO.update` call already uses. The -embedded connector stamps receive-time only as an interim measure until this -lands, per decision 10 of #388 — this is 1.0 scope, not a follow-up. - -**ControllerRunner:** Extract the controller lifecycle currently inlined in +**Setpoint cache.** `AttrW`/`AttrRW` retain the last-applied setpoint, exposed +via the sync `.setpoint` property from [ADR 14](0014-attribute-io-rw-rework.md)'s +runtime surface (the FastCS analogue of ophyd-async's +`SignalBackend.get_setpoint()`). `set(value)` caches it immediately — before +the setter runs and independent of whether the setter succeeds — so `.setpoint` +is a "what did we last ask for" query, distinct from `.readback` ("what did we +last read back"). This is available to all transports, not just the embedded +connector. + +**Native timestamps (+ severity).** A value entering an `AttrR`/`AttrRW` may +carry a timestamp and severity by arriving as an `Update[T]` +([ADR 14](0014-attribute-io-rw-rework.md)) — from a getter's return, a +value-returning setter, or a `@scan`/subscription `update()` push — defaulting +to framework receive-time when the timestamp is `None`. The timestamp/severity +pair **follows bluesky's `Reading` shape but shares no code** with it, and +severity is a **FastCS enum using the same strings as EPICS** alarm severities. +This is FastCS-native, not EPICS-specific — Tango event pushes and other IO can +supply a device-side timestamp through the same `Update[T]` path a getter +already uses. The embedded connector stamps receive-time only as an interim +measure until this lands, per decision 10 of #388 — this is 1.0 scope, not a +follow-up. + +**ControllerRunner.** Extract the controller lifecycle currently inlined in `FastCS.serve` into a standalone `ControllerRunner` (or equivalent `Controller.serve()`/`Controller.stop()` API), independent of the transport-serving and interactive-shell logic that stays in `FastCS`/ @@ -77,19 +83,20 @@ case). **Idempotency is the caller's responsibility**, not the runner's — the embedded connector's `connect_real` may run more than once across reconnects (see [ADR 19](0019-embedded-ophyd-async-connector.md)). -This, together with `ControllerAPI` and the attribute/command runtime -methods (`AttrR.get`/`add_on_update_callback`, `AttrW.put` + cached -setpoint, `Attribute.datatype`/`access_mode`/`description`/`group`), becomes -the documented stable surface referenced by decision 13 of #388. +This, together with `ControllerAPI` and the attribute/command runtime surface +from [ADR 14](0014-attribute-io-rw-rework.md) (`.readback`/`poll()` + +update-callback registration, `set()` + the `.setpoint` cache, +`attr.meta`/`access_mode`/`description`/`group`), becomes the documented stable +surface referenced by decision 13 of #388. ## Consequences - `FastCS.serve` shrinks to transport orchestration; the controller lifecycle it currently inlines becomes independently testable and reusable without instantiating a `FastCS` object or any `Transport`. -- Every `ReadIO.update` implementation *may* supply a timestamp/severity, - but existing IO that does not is unaffected — defaults to current time, - severity unset. +- Every getter/setter *may* return an `Update[T]` to supply a + timestamp/severity, but a bare value is unaffected — it defaults to + framework receive-time, severity unset. - Transports gain access to a real setpoint distinct from the readback value; whether EPICS/Tango/REST/GraphQL surface this as new fields is transport-specific follow-up work, not part of this ADR. @@ -97,12 +104,17 @@ the documented stable surface referenced by decision 13 of #388. narrow surface instead of `BaseController` internals — see [ADR 19](0019-embedded-ophyd-async-connector.md). -## Resolved in review (#402) - -- **Setpoint accessor:** a `AttrW.get_setpoint()` method (mirrors - `SignalBackend.get_setpoint()`). -- **Timestamp/severity:** follow bluesky's `Reading` shape but **share no - code**; severity is a **FastCS enum using the same strings as EPICS**. -- **`ControllerRunner`:** a class with `start()`/`stop()` (context manager only - if it also suits `FastCS()`); **idempotency is the caller's responsibility**. -- **The runner owns the whole lifecycle, including reconnect.** +## Questions resolved in review (#402) + +1. **How is the cached setpoint exposed?** Via the `.setpoint` property from + [ADR 14](0014-attribute-io-rw-rework.md)'s runtime surface (the FastCS + analogue of `SignalBackend.get_setpoint()`), cached by `set()` before the + setter runs. +2. **What shape do timestamp/severity take?** They follow bluesky's `Reading` + shape but **share no code**; severity is a **FastCS enum using the same + strings as EPICS**, carried on `Update[T]`. +3. **What is the runner's shape?** A class with `start()`/`stop()` (context + manager only if it also suits `FastCS()`); **idempotency is the caller's + responsibility**. +4. **Who owns reconnect?** The runner owns the whole lifecycle, including + reconnect. diff --git a/docs/explanations/decisions/0017-naming-pass.md b/docs/explanations/decisions/0017-naming-pass.md index a781e002e..85af66a07 100644 --- a/docs/explanations/decisions/0017-naming-pass.md +++ b/docs/explanations/decisions/0017-naming-pass.md @@ -2,7 +2,8 @@ Date: 2026-07-20 -**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 14](0014-attribute-io-rw-rework.md), [ADR 15](0015-typed-commands.md) ## Status @@ -28,76 +29,81 @@ hints are now load-bearing (they are what `ControllerFiller` scans), so having ophyd-async-compatible hint spellings for array/table attributes becomes more valuable than it was when hints were validation-only. +This pass now lives on python types + `*Meta` typed dicts, not on `DataType` +classes: the `DataType` family is dropped ([ADR 14](0014-attribute-io-rw-rework.md), +[ADR 15](0015-typed-commands.md)), so these renames fold into the per-attribute +IO rework (issue #392) rather than landing as a separate late PR. The concrete +`*Meta` mechanism (per-datatype `TypedDict`s, the superset `Meta` for extras, +`attr.meta` storage, the `Unpack` overloads) is specified in +[ADR 14](0014-attribute-io-rw-rework.md); the module home for these public +names is decided in #406. + Since this is a pre-1.0 breaking-change window (per #388's framing — "while breaking pre-1.0"), this is the point to make these renames, not after 1.0 when they become a deprecation cycle. ## Decision -> **Review update (#402): `DataType` is dropped** (see -> [ADR 15](0015-typed-commands.md)). The renames below now live on python -> types + `*Meta` typed dicts, not on `DataType` classes, and this pass folds -> into the `AttributeIO` rework ([ADR 14](0014-attribute-io-rw-rework.md) / -> issue #392) rather than a separate late PR. `Array1D`/`Table` become *both* -> the hint and the runtime structure passed around as the datatype — there is -> no separate `Waveform`/`DataType` object to map to. -> -> The concrete `*Meta` mechanism (per-datatype `TypedDict`s, the superset -> `Meta` for extras, `attr.meta` storage on the attribute, `Unpack` overloads) -> is specified in [ADR 14](0014-attribute-io-rw-rework.md); the module home for -> these public names is decided in #406. - -1. **`prec` → `precision`.** Rename across `_Numeric`/`Float`/wherever - `prec` appears (transports, docs, snippets). No behaviour change. -2. **Limits alignment.** Align `min`/`max`/`min_alarm`/`max_alarm` naming - with event-model `Limits` naming. This ADR records the *intent* - (converge with bluesky event-model naming so alarm/control/display limits - read the same way in FastCS and ophyd-async docs); the exact target - shape (keep four flat fields renamed, or restructure into a `Limits`-like - object) is an open question for the prototype, since it interacts with - how `DataType.validate` currently accesses these fields directly as - dataclass attributes. -3. **`Array1D`/`Table` hint spellings.** Adopt `Array1D[np.int32]` and - `Table` as the FastCS *hint* spellings a `ControllerFiller`-scanned class - body uses, mapping internally to the existing `Waveform`/table `DataType` - runtime objects (constructed the same way as today via - `AttrRW(Waveform(np.int32, shape=(4,)), io=...)` in procedural code) — - the hint is sugar for `ControllerFiller`'s type-hint scan, not a - replacement for the runtime `DataType` classes, matching decision 7 of - #388 (`DataType` classes stay as the procedural/runtime value; hints are - what `ControllerFiller` reads). +1. **`prec` → `precision`.** Rename across the numeric metadata (`FloatMeta`, + transports, docs, snippets) wherever `prec` appears. `precision` stays an + `int` (decimal places). No behaviour change. + +2. **Limits alignment — nested, not flat.** Replace the flat + `min`/`max`/`min_alarm`/`max_alarm` fields with a nested `Limits` structure + aligned to the bluesky event-model, so alarm/control/display limits read the + same way in FastCS and ophyd-async docs. **All four categories** — control, + display, alarm, warning — are present and **all optional**, with inheritance: + + - supply none ⇒ all unbounded; + - Display but not Control ⇒ Control inherits Display (for a writeable attr); + - Alarm but not Warning ⇒ Warning inherits Alarm; + - both Alarm and Warning ⇒ assert Warning ⊆ Alarm; + - otherwise unspecified ⇒ unbounded. + +3. **`Array1D`/`Table` hint spellings, which are also the runtime structure.** + Adopt `Array1D[np.int32]` and `Table` as the FastCS *hint* spellings a + `ControllerFiller`-scanned class body uses. With `DataType` dropped, these + are **both** the hint and the runtime structure passed around as the + datatype — there is no separate `Waveform`/table `DataType` object to map to. + Procedural construction passes the same types plus `*Meta` (e.g. + `AttrRW(Array1D[np.int32], shape=(4,), getter=...)`), and shape/array + metadata rides on `Array1DMeta` exactly as `precision`/`units` ride on + `FloatMeta`. This is explicitly the smallest naming-pass scope agreed in #388 for 1.0. A `Prec`/`Units`/`Shape` `Annotated` extras vocabulary (letting a hint carry -precision/units/shape without a full `DataType` instance) is called out in -#388 as a **post-1.0** option enabled by, but not required by, the +precision/units/shape without a spec object) is called out in #388 as a +**post-1.0** option enabled by, but not required by, the [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) extras mechanism — not part of this ADR. ## Consequences - Every driver using `Float(prec=...)`, `.min`/`.max`/`.min_alarm`/ - `.max_alarm` needs a mechanical rename. This is a wide, shallow diff - across all downstream repos (`fastcs-eiger`, `fastcs-catio`, - `fastcs-secop`, `fastcs-PandABlocks` all use `Float`/numeric limits - somewhere) but not a structural one, unless the Limits restructuring - (open question 2) turns out to be more than a rename. + `.max_alarm` needs a rename to `precision` and the nested `Limits` + structure. This is a wide diff across all downstream repos (`fastcs-eiger`, + `fastcs-catio`, `fastcs-secop`, `fastcs-PandABlocks` all use numeric + limits somewhere); the flat→nested Limits change is structural, not purely a + rename. - Transports serving `precision`/limits metadata (EPICS record fields, - Tango attribute properties, REST/GraphQL schema) need their field-name - mapping updated to read from the renamed dataclass fields. -- `Array1D`/`Table` hint spellings only affect declarative (hinted) - attribute declarations; procedural construction with `Waveform(...)`/ - `Table(...)` DataType instances is unchanged. - -## Resolved in review (#402) - -1. **Limits are nested**, not four flat fields. -2. **All four categories (control/display/alarm/warning), all optional**, with - inheritance: supply none ⇒ all unbounded; Display but not Control ⇒ Control - inherits Display (for writeable); Alarm but not Warning ⇒ Warning inherits - Alarm; both ⇒ assert Warning ⊆ Alarm; otherwise unspecified ⇒ unbounded. -3. **`precision` stays an `int`** (decimal places). -4. **`Array1D` is both the hint and the runtime structure** — with `DataType` - dropped it falls out in the wash; there is no `Waveform` object to map to. -5. **Where it lands is the implementer's choice** — folds naturally into the - `AttributeIO`/DataType-drop PR (#392). + Tango attribute properties, REST/GraphQL schema) read these from `attr.meta` + ([ADR 14](0014-attribute-io-rw-rework.md)) and need their field-name mapping + updated to the renamed / nested fields. +- `Array1D`/`Table` become the single array/table representation for both + hinted and procedural attributes, so there is no hint-vs-runtime mapping + layer to keep in sync. + +## Questions resolved in review (#402) + +1. **Flat or nested limits?** Nested — a `Limits` structure, not four flat + fields. +2. **Which limit categories, and how do they combine?** All four + (control/display/alarm/warning), all optional, with the inheritance rules in + Decision point 2 (Control inherits Display, Warning inherits Alarm, assert + Warning ⊆ Alarm, otherwise unbounded). +3. **Is `precision` an int or a float?** An `int` (decimal places). +4. **Do `Array1D`/`Table` map onto a separate runtime `DataType`?** No — with + `DataType` dropped they *are* both the hint and the runtime structure; there + is no `Waveform` object to map to. +5. **Where does this land?** It folds naturally into the per-attribute IO / + `DataType`-drop PR (#392) — the implementer's choice, not a separate late PR. diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index 87a1924cc..923364a25 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -2,7 +2,9 @@ Date: 2026-07-20 -**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 14](0014-attribute-io-rw-rework.md) ## Status @@ -23,43 +25,40 @@ def current(self) -> float: Under the [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) harsh declarative/procedural split (bare hints only in the class body; all -IO wiring procedural), the equivalent trivial case regresses to a method -plus a `CallbackReadIO` adapter plus explicit `__init__` wiring — strictly -more ceremony than PyTango for the simple case that most new users hit -first. This is exactly the kind of "false friend" gap #388 warns about: a -PyTango user evaluating FastCS should not find the *simple* case harder than -what they're moving away from. +IO wiring procedural), the equivalent trivial case would regress to a method +plus explicit `AttrR(getter=...)` wiring in `__init__` — strictly more +ceremony than PyTango for the simple case that most new users hit first. This +is exactly the kind of "false friend" gap #388 warns about: a PyTango user +evaluating FastCS should not find the *simple* case harder than what they're +moving away from. FastCS already has precedent for binding class-body decorated methods to per-instance callables without any deepcopy hazard: `@command`/`@scan` (`src/fastcs/methods/command.py`, `scan.py`) use `UnboundCommand`/ `UnboundScan`, which wrap an unbound function and `.bind(controller)` a -fresh `Command`/`Scan` object per instance at `_bind_attrs` time. Because +fresh `Command`/`Scan` object per instance at construction time. Because these are fresh objects constructed per-instance (not deepcopied prototypes), they carry none of the aliasing hazard that class-scope -`Attribute` *instances* had — which is why [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) +`Attribute` *instances* had — which is why +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md) removes the latter but keeps `@command`/`@scan`. ## Decision -> **Review update (#402): the decorator is `@attr`, mirroring `@property`.** -> `@attr` on the getter, `@voltage.setter` on the writer (not `@attr_r`/ -> `@attr_rw`/`.send`). It unifies with the callback IO from -> [ADR 14](0014-attribute-io-rw-rework.md): `self.x = attr(getter=…, setter=…)` -> is the `__init__` spelling of the same thing. `AttrW`-only (write with no -> paired getter) is rare, so it is written longhand rather than given its own -> decorator. - -Add `@attr` as pure sugar over `AttrR`/`AttrRW` plus a generated -callback-based `io=`, built on the same `Unbound*`-style bind machinery as -`@command`/`@scan` — fresh objects per instance, no prototype/deepcopy -hazard, consistent with keeping this a class-body citizen under -[ADR 13](0013-declarative-procedural-split-and-controller-filler.md). +Add `@attr` as pure sugar over `AttrR`/`AttrRW` plus generated getter/setter +callables ([ADR 14](0014-attribute-io-rw-rework.md)), built on the same +`Unbound*`-style bind machinery as `@command`/`@scan` — fresh objects per +instance, no prototype/deepcopy hazard, consistent with keeping this a +class-body citizen under +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md). The +decorator mirrors `@property`: `@attr` on the getter, `@voltage.setter` on the +writer. ```python class PowerSupply(Controller): - @attr(units="V", update_period=0.5) # dtype inferred from -> float + @attr(units="V", poll_period=0.5) # datatype inferred from -> float async def voltage(self) -> float: + """Output voltage.""" return await self._conn.query("V?") @voltage.setter @@ -68,64 +67,76 @@ class PowerSupply(Controller): ``` - The datatype is inferred from the return type annotation of the getter - (`-> float` → `Float()`), matching how `DataType` mapping already works - elsewhere (`numpy_to_fastcs_datatype`), rather than requiring a `dtype=` - keyword the way PyTango does — decision 14 of #388 explicitly calls this - out as *better* than PyTango's `dtype=` kwarg since it's one real - annotation, checked statically. -- `@attr` comes in two forms: bare `@attr` and parameterised `@attr(precision=3, - units="V", update_period=0.5)`; the keyword arguments map onto the same `*Meta` - fields and the `getter`/`setter` + `update_period` constructor arguments of - `AttrR`/`AttrRW` ([ADR 14](0014-attribute-io-rw-rework.md)) — sugar over that - mechanism, not a parallel one. There is **no** free-function `attr()` factory: - the procedural spelling is `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` - directly. + (`-> float` → a `float` attribute), matching how the datatype is inferred + from a getter's annotation on the procedural + `AttrR(getter=…)`/`AttrRW(getter=…, setter=…)` form + ([ADR 14](0014-attribute-io-rw-rework.md)), rather than requiring a `dtype=` + keyword the way PyTango does — decision 14 of #388 explicitly calls this out + as *better* than PyTango's `dtype=` kwarg since it's one real annotation, + checked statically. +- `@attr` comes in two forms: bare `@attr` and parameterised + `@attr(precision=3, units="V", poll_period=0.5)`; the keyword arguments map + onto the same `*Meta` fields (typed with `Unpack[…Meta]`, validated against + the getter's return type) and the `poll_period` read-side kwarg of + `AttrR`/`AttrRW` — sugar over that mechanism, not a parallel one. - `@attr`'s `.setter` decorator mirrors `@property`/`@x.setter`, giving the - read+write pair a single logical name (`voltage`) with two decorated - methods. (`.send` is not used.) -- This degrades gracefully into the procedural `AttrR`/`AttrRW(getter=…, - setter=…)` form for protocol families with more complex needs, and into - [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s - filler for introspection-driven attributes — `@attr` is explicitly the - *simple* case, not a replacement for either. + read+write pair a single logical name (`voltage`) with two decorated methods. + There is **no dedicated write-only decorator** — a paired-getter-less `AttrW` + is rare, so it is written longhand as `AttrW(setter=…)`. +- The getter's docstring becomes the attribute's `description`, as + `@command`/`@scan` already do. +- `@attr` supports the [ADR 17](0017-naming-pass.md) `Array1D`/`Table` hint + spellings as the getter's return annotation. +- There is **no** free-function `attr()` factory: the procedural spelling is + `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` directly. `@attr` degrades + gracefully into that procedural form for protocol families with more complex + needs, and into + [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s filler + for introspection-driven attributes — `@attr` is explicitly the *simple* + case, not a replacement for either. - Refines the class-body rule stated in [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) to: *class body = declarations + decorated behaviour; instance scope = construction with data* — already true today via `@command`/`@scan`, now extended to attributes. -- Docs gain a "FastCS for PyTango users" page pairing this decorator with - the equivalent PyTango snippet, landing alongside this PR per #388 §8 - item 5b. +- Docs gain a "FastCS for PyTango users" page pairing this decorator with the + equivalent PyTango snippet, landing alongside this PR per #388 §8 item 5b. + +Interaction with the filler: an `@attr`-decorated attribute is already defined, +so [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s +`ControllerFiller` treats it as filled and does not shadow it; a clash between +an introspected name and a decorated name raises. ## Consequences - New driver code for the common "one attribute, one device call" case gets noticeably shorter — closing the gap #388 §7.5 identifies against PyTango. -- The generated callback `io=` **is** the unified callback mechanism from - [ADR 14](0014-attribute-io-rw-rework.md) (which no longer ships separate - `CallbackReadIO`/`CallbackWriteIO`): `@attr` and `attr(getter=…)` are two - spellings over one implementation. -- Adds a third way to declare an attribute (bare hint + filler; explicit - `AttrRW(getter=…, setter=…)`; `@attr` sugar) — the docs need to be clear about - when to reach for which, so this doesn't become three equally-weighted +- `@attr` and the procedural `AttrR(getter=…)` / `AttrRW(getter=…, setter=…)` + form are two spellings over one implementation — the generated getter/setter + from [ADR 14](0014-attribute-io-rw-rework.md), with no separate callback-IO + classes. +- There are three ways to declare an attribute (bare hint + filler; explicit + `AttrRW(getter=…, setter=…)`; `@attr` sugar) — the docs need to be clear + about when to reach for which, so this doesn't become three equally-weighted options with no guidance, undermining the "harsh split" clarity [ADR 13](0013-declarative-procedural-split-and-controller-filler.md) is trying to establish. -- Per #388 §8 item 5b, this sits on PR 1 (the `AttributeIO` rework) — - small and independent of the `ControllerFiller` work — so it can land - early and not block on [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). - -## Resolved in review (#402) - -1. **Decorator is `@attr` + `@x.setter`** (property-mirroring), not - `@attr_r`/`@attr_rw`/`.send`. No dedicated write-only decorator — `AttrW` - alone is rare, written longhand. -2. **Datatype/limits metadata passes via decorator kwargs**, typed with - `Unpack[…Meta]` (`precision`, `units`, limits — the ADR 14/17 `*Meta` - fields), validated against the getter's return type. -3. **Supports the ADR 17 `Array1D`/`Table` hints.** -4. **`@attr`-decorated attrs are treated specially by the filler** — already - defined, so not shadowed; a clash between an introspected name and a - decorated name raises. -5. **Yes — the getter's docstring becomes the attribute's `description`** - (as `@command`/`@scan` already do). +- Per #388 §8 item 5b, this sits on PR 1 (the per-attribute IO rework) — small + and independent of the `ControllerFiller` work — so it can land early and not + block on [ADR 13](0013-declarative-procedural-split-and-controller-filler.md). + +## Questions resolved in review (#402) + +1. **What is the decorator spelling?** `@attr` + `@x.setter` + (property-mirroring), not `@attr_r`/`@attr_rw`/`.send`. No dedicated + write-only decorator — `AttrW` alone is rare, written longhand. +2. **How is datatype/limits metadata passed?** Via decorator kwargs, typed with + `Unpack[…Meta]` (`precision`, `units`, limits — the + [ADR 14](0014-attribute-io-rw-rework.md)/[ADR 17](0017-naming-pass.md) + `*Meta` fields), validated against the getter's return type. +3. **Does it support the `Array1D`/`Table` hints?** Yes, as the getter's return + annotation. +4. **How does the filler treat a decorated attr?** As already defined — not + shadowed; a clash between an introspected name and a decorated name raises. +5. **Does the getter's docstring become the `description`?** Yes, as + `@command`/`@scan` already do. diff --git a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md index 92ba31c3f..b61c43704 100644 --- a/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md +++ b/docs/explanations/decisions/0019-embedded-ophyd-async-connector.md @@ -2,7 +2,10 @@ Date: 2026-07-20 -**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388) +**Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), +[ADR 13](0013-declarative-procedural-split-and-controller-filler.md), +[ADR 14](0014-attribute-io-rw-rework.md), [ADR 15](0015-typed-commands.md), +[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) ## Status @@ -32,8 +35,9 @@ for [ADR 13](0013-declarative-procedural-split-and-controller-filler.md)'s than trying to fill eagerly. - `SignalBackend`'s methods (`get_value`, `get_setpoint`, `set_callback`, `put`, `get_datakey`) are the exact surface a `FastCSSignalBackend` needs - to implement in terms of FastCS's `AttrR.get`/`AttrW.put`/setpoint cache/ - native timestamps (from [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). + to implement in terms of FastCS's `.readback`/`.set()`/setpoint cache/native + timestamps (from [ADR 14](0014-attribute-io-rw-rework.md) and + [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). - `CommandBackend.execute`/`.signature` is the equivalent surface for typed commands (from [ADR 15](0015-typed-commands.md)). @@ -86,10 +90,12 @@ Mechanics, directly mirroring `PviDeviceConnector`/`TangoDeviceConnector`: usage stays free (no FastCS controller/connection is instantiated at all). - Lifecycle (decision 8 of #388): the connector owns the runner; shutdown via an `atexit` hook plus an explicit `await connector.shutdown()`, which - cancels scan tasks and calls `Controller.disconnect()`. **The - `Device.disconnect()` proposal is dropped** — reconnect is - `Device.connect(force_reconnect=True)`, and the only disconnect we want is - `atexit` (review #402). + cancels scan tasks and calls `Controller.disconnect()`. Reconnect is + `Device.connect(force_reconnect=True)`; there is no `Device.disconnect()` + proposal, and the only disconnect we want is `atexit`. +- Errors: FastCS gains a `ConnectionFailedError` (raised when the device + doesn't respond); the connector converts it to `NotConnectedError` and keeps + retrying to connect in the background. All other errors surface unconverted. - Embedded + transports simultaneously (decision 9 of #388, e.g. a CA GUI running next to a bluesky plan) is explicitly out of scope for the first cut, but the `ControllerRunner` is designed so a transport list can be @@ -100,20 +106,20 @@ Backend mappings (from #388 §5, grounded against the researched | ophyd-async | FastCS | |---|---| -| `SignalBackend.get_value` | `AttrR.get()` | -| `SignalBackend.set_callback` | `AttrR.add_on_update_callback(cb, always=True)`; stamped per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | -| `SignalBackend.put` | `AttrW.put(value)` | -| `SignalBackend.get_setpoint` | `AttrW` cached setpoint, [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.get_value` | `AttrR.readback` | +| `SignalBackend.set_callback` | update-callback registration (`always=True`); stamped per [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | +| `SignalBackend.put` | `AttrW.set(value)` | +| `SignalBackend.get_setpoint` | `AttrW.setpoint` cache, [ADR 14](0014-attribute-io-rw-rework.md)/[ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md) | | `SignalBackend.get_datakey` | `attr.meta` (units, precision, limits) + python-type/enum choices → `SignalMetadata` + `make_datakey` | | `CommandBackend.execute`/`.signature` | `Command.__call__` / captured `Signature`, [ADR 15](0015-typed-commands.md) | | `SignalBackend.source` | e.g. `fastcs://.` | | child `Device` / `DeviceVector` | sub-`Controller` / `ControllerVector` | | (not exposed) | `@scan` methods — purely-internal periodic coroutines, bound to no `Attr`, not surfaced to ophyd-async (@shihab-dls, #402) | -Datatype mapping: `Int`/`Float`/`Bool`/`String` → `int`/`float`/`bool`/`str`; -`Waveform(array_dtype, shape)`/`Array1D` hint (per -[ADR 17](0017-naming-pass.md)) → ophyd-async `Array1D[dtype]`; `Enum(cls)` → -the enum class itself. Resolved in review (#402): +Datatype mapping: `int`/`float`/`bool`/`str` map straight across; the +`Array1D[dtype]` hint/runtime type (per [ADR 17](0017-naming-pass.md)) → +ophyd-async `Array1D[dtype]`; an enum class → the enum class itself. Two cases +needed a decision: - **Enums:** un-hinted enum classes introspect at runtime and drop to a string datatype retaining the choices as metadata; hint-typed enums require the @@ -142,28 +148,32 @@ the enum class itself. Resolved in review (#402): - `fastcs-demo`'s temperature controller simulation (`fastcs.demo.simulation`) is the existing sim device ophyd-async tests against — no new simulated device is needed for the first cut. - -## Resolved in review (#402) - -- **Enums:** un-hinted → runtime-introspect, drop to string keeping choices as - metadata; hinted → require `StrictEnum`/`SubsetEnum`/`SupersetEnum` - duplication for now, revisit with use cases. -- **`Table`:** bidirectional converter in scope for the first cut; use it to - converge the two `Table` implementations. -- **Errors:** FastCS gains a `ConnectionFailedError` (raised when the device - doesn't respond); the connector converts it to `NotConnectedError` and keeps - retrying to connect in the background. All other errors surface unconverted. -- **Disconnect dropped:** reconnect is `Device.connect(force_reconnect=True)`; - the only disconnect is `atexit`. No `Device.disconnect()` proposal (so #388 - §8 item 8 / issue #401 is rewritten accordingly). -- **`@scan` surfaces nothing; `@command` does (@shihab-dls, #402):** confirmed — - a `@scan`-decorated method is a **purely internal** coroutine run periodically; - it is *not* bound to an `Attr` and produces **no** `Signal`. All exposed state - already lives in `Attr` instances: a getter-based `AttrR` schedules its getter - as a scan-style task *and* is bound to the Attr (→ `Signal`), and a soft `AttrR` - fed by `@scan` via `update()` is likewise the exposed Signal — so `@scan` needs - nothing extra surfaced. A `@command` method **is** different: it creates an - `AttrW` and **is** exposed (the `CommandBackend` row above). Both `@scan` and - getter/update coroutines are collected onto the running loop in - `create_api_and_tasks()`; the connector schedules `@scan` coroutines as internal - tasks but never maps them to Signals. +- Dropping `Device.disconnect()` means #388 §8 item 8 / issue #401 is + rewritten accordingly (reconnect via `force_reconnect=True`, disconnect via + `atexit` only). + +## Questions resolved in review (#402) + +1. **How are enums mapped?** Un-hinted → runtime-introspect, drop to string + keeping choices as metadata; hinted → require + `StrictEnum`/`SubsetEnum`/`SupersetEnum` duplication for now, revisit with + use cases. +2. **Is `Table` supported in the first cut?** Yes — a bidirectional converter, + used to converge the two `Table` implementations. +3. **How are connection errors handled?** FastCS gains a + `ConnectionFailedError`; the connector converts it to `NotConnectedError` and + keeps retrying to connect in the background. All other errors surface + unconverted. +4. **Is there a `Device.disconnect()`?** No — reconnect is + `Device.connect(force_reconnect=True)`; the only disconnect is `atexit`. +5. **Where does `@scan`-derived state that isn't a `Signal` go?** (@shihab-dls.) + Nowhere extra is needed. A `@scan`-decorated method is a **purely internal** + coroutine run periodically; it is *not* bound to an `Attr` and produces **no** + `Signal`. All exposed state already lives in `Attr` instances: a getter-based + `AttrR` schedules its getter as a scan-style task *and* is bound to the Attr + (→ `Signal`), and a soft `AttrR` fed by `@scan` via `update()` is likewise + the exposed Signal — so `@scan` surfaces nothing extra. A `@command` method + **is** different: it creates an `AttrW` and **is** exposed (the + `CommandBackend` row above). Both `@scan` and getter/update coroutines are + collected onto the running loop in `create_api_and_tasks()`; the connector + schedules `@scan` coroutines as internal tasks but never maps them to Signals. From da9988c5d8ed22a223b87a4c75eeaf8aef988b0c Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 23 Jul 2026 14:59:15 +0000 Subject: [PATCH 22/36] demo(#391): address review - simpler sim flip, backdoor, front-door tests - simulation/eiger.py: replace the sine sweep with a simple flip between two known temperatures every 0.5s (predictable); expose the parameter tree via `app.state.sim` as a test backdoor for read-only params with no PUT route. - tests: drop the sim-only tests; drive everything through the controller attributes. Idle test now pokes `state` via the sim backdoor and polls the attribute (rather than calling AttrR.update directly). Oscillation test builds a controller under the app lifespan and observes temperature via subscribe. Co-Authored-By: Claude Opus 4.8 --- src/fastcs/demo/simulation/eiger.py | 35 ++++---- tests/demo/test_eiger.py | 119 +++++++++++++--------------- 2 files changed, 74 insertions(+), 80 deletions(-) diff --git a/src/fastcs/demo/simulation/eiger.py b/src/fastcs/demo/simulation/eiger.py index 000bf63e6..397d31cde 100644 --- a/src/fastcs/demo/simulation/eiger.py +++ b/src/fastcs/demo/simulation/eiger.py @@ -8,8 +8,6 @@ """ import asyncio -import math -import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass @@ -23,6 +21,10 @@ API_PREFIX = "/detector/api/1.8.0" +# The sim flips its temperature between these two values so the front end has +# something visibly changing to poll. +TEMPERATURES = (20.0, 30.0) + @dataclass class EigerParameter: @@ -48,26 +50,20 @@ def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: async def _oscillate_temperature( - parameter: EigerParameter, - low: float = 20.0, - high: float = 30.0, - period: float = 10.0, + parameter: EigerParameter, period: float = 0.5 ) -> None: - """Slowly sweep a temperature parameter between two values, forever. + """Flip a temperature parameter between two known values forever. - Gives the front end something visibly changing to poll. Runs as a background - task under the app's lifespan (started by a real server, e.g. uvicorn; not by - the in-process ASGI transport used in tests, which keeps those deterministic). + Runs as a background task under the app's lifespan (started by a real server, + e.g. uvicorn). The in-process ASGI transport used by the controller in tests + does not start lifespan events, so a test that wants the task running drives + the lifespan explicitly. """ - mid = (low + high) / 2 - amplitude = (high - low) / 2 - start = time.monotonic() + index = 0 while True: - elapsed = time.monotonic() - start - parameter.value = round( - mid + amplitude * math.sin(2 * math.pi * elapsed / period), 1 - ) - await asyncio.sleep(0.1) + await asyncio.sleep(period) + index = 1 - index + parameter.value = TEMPERATURES[index] def create_eiger_sim_app() -> FastAPI: @@ -85,6 +81,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: task.cancel() app = FastAPI(lifespan=lifespan) + # Backdoor: expose the parameter tree so tests can set read-only values (e.g. + # ``state``, which has no PUT route) and then poll them through the controller. + app.state.sim = state def _subsystem(subsystem: str) -> dict[str, EigerParameter]: try: diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py index 8f5e4b6e1..04029190a 100644 --- a/tests/demo/test_eiger.py +++ b/tests/demo/test_eiger.py @@ -3,59 +3,35 @@ import httpx import pytest import pytest_asyncio -from fastapi.testclient import TestClient from fastcs.attributes import AttrR, AttrRW from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector -from fastcs.demo.simulation.eiger import API_PREFIX, create_eiger_sim_app +from fastcs.demo.simulation.eiger import EigerParameter, create_eiger_sim_app from fastcs.util import ONCE +# Backdoor to the sim's parameter tree, keyed by subsystem then param name. +SimState = dict[str, dict[str, EigerParameter]] -@pytest.fixture -def sim_client() -> TestClient: - return TestClient(create_eiger_sim_app()) - -def test_sim_lists_keys(sim_client: TestClient): - response = sim_client.get(f"{API_PREFIX}/config/keys") - assert response.status_code == 200 - assert set(response.json()) >= {"count_time", "frame_time", "nimages"} - - -def test_sim_get_parameter(sim_client: TestClient): - response = sim_client.get(f"{API_PREFIX}/config/count_time") - assert response.status_code == 200 - body = response.json() - assert body == {"value": 0.1, "value_type": "float", "access_mode": "rw"} - - -def test_sim_put_parameter(sim_client: TestClient): - response = sim_client.put(f"{API_PREFIX}/config/count_time", json={"value": 0.5}) - assert response.status_code == 200 - assert response.json() == {"value": 0.5} - - response = sim_client.get(f"{API_PREFIX}/config/count_time") - assert response.json()["value"] == 0.5 - - -def test_sim_put_read_only_parameter_rejected(sim_client: TestClient): - response = sim_client.put(f"{API_PREFIX}/status/state", json={"value": "busy"}) - assert response.status_code == 403 +@pytest_asyncio.fixture +async def _eiger(): + app = create_eiger_sim_app() + controller = EigerDetector(transport=httpx.ASGITransport(app=app)) + await controller.connect() + await controller.initialise() + controller.post_initialise() + yield controller, app.state.sim + await controller.disconnect() -def test_sim_unknown_parameter_404(sim_client: TestClient): - assert sim_client.get(f"{API_PREFIX}/config/nonexistent").status_code == 404 - assert sim_client.get(f"{API_PREFIX}/nonexistent/keys").status_code == 404 +@pytest_asyncio.fixture +async def detector(_eiger) -> EigerDetector: + return _eiger[0] @pytest_asyncio.fixture -async def detector() -> EigerDetector: - transport = httpx.ASGITransport(app=create_eiger_sim_app()) - controller = EigerDetector(transport=transport) - await controller.connect() - await controller.initialise() - controller.post_initialise() - return controller +async def sim(_eiger) -> SimState: + return _eiger[1] @pytest.mark.asyncio @@ -78,29 +54,33 @@ async def test_read_attribute_from_device(detector: EigerDetector): await detector.count_time.bind_update_callback()() assert detector.count_time.get() == 0.1 - temperature = detector.attributes["temperature"] - assert isinstance(temperature, AttrR) - await temperature.bind_update_callback()() - assert temperature.get() == 22.5 + humidity = detector.attributes["humidity"] + assert isinstance(humidity, AttrR) + await humidity.bind_update_callback()() + assert humidity.get() == 32.1 @pytest.mark.asyncio async def test_write_attribute_to_device(detector: EigerDetector): await detector.count_time.put(0.5) - response = await detector.connection.get("config", "count_time") - assert response["value"] == 0.5 + # Read it back through the attribute to confirm the round-trip to the device. + await detector.count_time.bind_update_callback()() + assert detector.count_time.get() == 0.5 @pytest.mark.asyncio -async def test_idle_derived_from_state(detector: EigerDetector): +async def test_idle_derived_from_state(detector: EigerDetector, sim: SimState): # ``idle`` is soft and starts at its default, tracking ``state`` once polled. assert detector.idle.get() is False - await detector.state.update("idle") + # Poke the read-only ``state`` via the sim backdoor, then poll the attribute. + sim["status"]["state"].value = "idle" + await detector.state.bind_update_callback()() assert detector.idle.get() is True - await detector.state.update("acquire") + sim["status"]["state"].value = "acquire" + await detector.state.bind_update_callback()() assert detector.idle.get() is False @@ -115,17 +95,32 @@ async def test_read_only_params_poll_but_rw_read_once(detector: EigerDetector): @pytest.mark.asyncio -async def test_sim_temperature_oscillates(): - # The background task only runs under the app lifespan (a real server), not the - # bare ASGI transport used elsewhere, so drive the lifespan explicitly here. +async def test_temperature_oscillation_seen_via_subscribe(): + # The oscillation task runs under the app lifespan, so drive the lifespan here + # (the bare ASGI transport used elsewhere does not start it). Observe it through + # the controller's temperature attribute, subscribing for updates. app = create_eiger_sim_app() - transport = httpx.ASGITransport(app=app) async with app.router.lifespan_context(app): - async with httpx.AsyncClient(base_url="http://sim", transport=transport) as c: - readings = [] - for _ in range(4): - await asyncio.sleep(0.3) - response = await c.get(f"{API_PREFIX}/status/temperature") - readings.append(response.json()["value"]) - - assert len(set(readings)) > 1, f"temperature did not change: {readings}" + controller = EigerDetector(transport=httpx.ASGITransport(app=app)) + await controller.connect() + await controller.initialise() + controller.post_initialise() + + temperature = controller.attributes["temperature"] + assert isinstance(temperature, AttrR) + + seen: list[float] = [] + + async def record(value: float) -> None: + seen.append(value) + + temperature.add_on_update_callback(record) + + # Poll across several sim flips (every 0.5s) so the value changes under us. + for _ in range(8): + await temperature.bind_update_callback()() + await asyncio.sleep(0.2) + + await controller.disconnect() + + assert len(set(seen)) > 1, f"temperature did not change: {seen}" From 8f3be18f16a2f495c1b94c79558c107e0ab85977 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 30 Jul 2026 16:14:32 +0000 Subject: [PATCH 23/36] test(demo): assert cancel_all puts Off on each ramp's enabled attr Check the behaviour cancel_all is responsible for (disabling every ramp via its `enabled` attribute) rather than the wire-format strings the attribute IO layer happens to emit. Co-Authored-By: Claude Opus 5 --- tests/demo/test_controllers.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/demo/test_controllers.py b/tests/demo/test_controllers.py index dd0adab82..bd7775bdf 100644 --- a/tests/demo/test_controllers.py +++ b/tests/demo/test_controllers.py @@ -6,6 +6,7 @@ from fastcs.connections import IPConnectionSettings from fastcs.controllers import ControllerVector from fastcs.demo.controllers import ( + OnOffEnum, TemperatureController, TemperatureControllerSettings, TemperatureRampController, @@ -33,15 +34,15 @@ def test_ramps_is_controller_vector(controller: TemperatureController): @pytest.mark.asyncio async def test_cancel_all_disables_every_ramp(controller: TemperatureController): - controller.connection.send_command = AsyncMock() # type: ignore[method-assign] + puts = {} + for index, ramp in controller.ramps.items(): + puts[index] = AsyncMock() + ramp.enabled.put = puts[index] # type: ignore[method-assign] await controller.cancel_all() - sent_commands = [ - call.args[0] for call in controller.connection.send_command.call_args_list - ] - for index in controller.ramps: - assert f"N{index:02d}=0\r\n" in sent_commands + for put in puts.values(): + put.assert_awaited_once_with(OnOffEnum.Off, sync_setpoint=True) @pytest.mark.asyncio From 34196c6a62377cd335c12ded388398aaf0b57236 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 30 Jul 2026 16:27:51 +0000 Subject: [PATCH 24/36] demo(#391): introspect state as an enum from allowed_values; drop IO cast - Sim: EigerParameter gains allowed_values, reported by GET only for discrete params (as the real detector does). state now advertises [idle, ready, acquire]. - Controller: a param reporting allowed_values is introspected as an Enum over an enum class built from those values. The members are only knowable over the wire, so state's hint drops to a bare AttrR - the exact-dtype hint check has no author-time class to match against. - EigerAttributeIO.update no longer casts to the dtype; attr.update validates, which is the one place a bad device value should be coerced or complained about. Co-Authored-By: Claude Opus 5 --- src/fastcs/demo/eiger.py | 44 ++++++++++++++++++++++------- src/fastcs/demo/simulation/eiger.py | 16 +++++++++-- tests/demo/test_eiger.py | 25 ++++++++++++---- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py index 021dad7ca..7ebea02fe 100644 --- a/src/fastcs/demo/eiger.py +++ b/src/fastcs/demo/eiger.py @@ -9,14 +9,15 @@ examples. """ +import enum from dataclasses import KW_ONLY, dataclass -from typing import Any +from typing import Any, cast import httpx from fastcs.attributes import AnyAttributeIO, AttributeIO, AttributeIORef, AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Bool, DataType, Float, Int, String +from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType from fastcs.util import ONCE @@ -31,6 +32,25 @@ UPDATE_PERIOD = 0.2 +def _datatype(param: str, data: dict[str, Any]) -> DataType: + """Build a datatype for a parameter from the metadata the device reports. + + A parameter that reports ``allowed_values`` is discrete, so it becomes an `Enum` + over an enum class built from those values. The members are only knowable over the + wire, which is exactly the case introspection exists for. + """ + allowed_values = data.get("allowed_values") + if allowed_values is None: + return _DATATYPES[data["value_type"]]() + + name = "".join(part.title() for part in param.split("_")) + # The functional API builds a class; type checkers only see the instance signature. + enum_cls = cast( + type[enum.Enum], enum.Enum(name, {value: value for value in allowed_values}) + ) + return Enum(enum_cls) + + @dataclass class EigerConnectionSettings: base_url: str = "http://localhost:8000" @@ -95,7 +115,9 @@ def __init__(self, connection: EigerConnection): async def update(self, attr: AttrR[Any, EigerAttributeIORef]) -> None: data = await self._connection.get(attr.io_ref.subsystem, attr.io_ref.param) - await attr.update(attr.dtype(data["value"])) + # No cast here - ``update`` validates against the datatype, which is the one + # place a bad value from the device should be coerced or complained about. + await attr.update(data["value"]) async def send(self, attr, value) -> None: await self._connection.put(attr.io_ref.subsystem, attr.io_ref.param, value) @@ -105,9 +127,11 @@ class EigerDetector(Controller): """Cut-down Eiger controller: half declared, half introspected.""" # Declared (checked): must exist, with this access mode and dtype, after - # initialise() introspects the parameter tree. + # initialise() introspects the parameter tree. ``state`` is discrete, and its + # enum class is built from the ``allowed_values`` the device reports, so there + # is no author-time type to hint - only the access mode can be pinned here. count_time: AttrRW[float] - state: AttrR[str] + state: AttrR # Derived (soft): built on top of the introspected ``state`` param. Declaring # ``state`` as a checked attribute is what lets us reference it in code and @@ -136,23 +160,23 @@ async def initialise(self) -> None: for subsystem in ("config", "status"): for param in await self.connection.keys(subsystem): data = await self.connection.get(subsystem, param) - datatype_cls = _DATATYPES[data["value_type"]] + datatype = _datatype(param, data) if data["access_mode"] == "rw": io_ref = EigerAttributeIORef(subsystem=subsystem, param=param) - attr = AttrRW(datatype_cls(), io_ref=io_ref) + attr = AttrRW(datatype, io_ref=io_ref) else: # Read-only params are status values that change on the device, # so poll them periodically rather than reading once. io_ref = EigerAttributeIORef( subsystem=subsystem, param=param, update_period=UPDATE_PERIOD ) - attr = AttrR(datatype_cls(), io_ref=io_ref) + attr = AttrR(datatype, io_ref=io_ref) self.add_attribute(param, attr) # Keep the derived ``idle`` flag in sync with the introspected ``state``. self.state.add_on_update_callback(self._update_idle) - async def _update_idle(self, state: str) -> None: - await self.idle.update(state == "idle") + async def _update_idle(self, state: enum.Enum) -> None: + await self.idle.update(state.value == "idle") diff --git a/src/fastcs/demo/simulation/eiger.py b/src/fastcs/demo/simulation/eiger.py index 397d31cde..b70488d7e 100644 --- a/src/fastcs/demo/simulation/eiger.py +++ b/src/fastcs/demo/simulation/eiger.py @@ -31,6 +31,12 @@ class EigerParameter: value: Any value_type: ValueType access_mode: AccessMode = "r" + allowed_values: list[str] | None = None + """The permitted values of a discrete parameter, as the real detector reports them. + + Only discrete parameters carry this, and it is the metadata a client needs to + introspect the parameter as an enum rather than a bare string. + """ def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: @@ -42,7 +48,9 @@ def _initial_state() -> dict[Subsystem, dict[str, EigerParameter]]: "description": EigerParameter("Simulated Eiger", "string", "r"), }, "status": { - "state": EigerParameter("idle", "string", "r"), + "state": EigerParameter( + "idle", "string", "r", allowed_values=["idle", "ready", "acquire"] + ), "temperature": EigerParameter(22.5, "float", "r"), "humidity": EigerParameter(32.1, "float", "r"), }, @@ -108,11 +116,15 @@ async def get_keys(subsystem: str) -> list[str]: @app.get(API_PREFIX + "/{subsystem}/{param}") async def get_parameter(subsystem: str, param: str) -> dict[str, Any]: parameter = _parameter(subsystem, param) - return { + data: dict[str, Any] = { "value": parameter.value, "value_type": parameter.value_type, "access_mode": parameter.access_mode, } + # Only discrete parameters report their options, as on the real detector. + if parameter.allowed_values is not None: + data["allowed_values"] = parameter.allowed_values + return data @app.put(API_PREFIX + "/{subsystem}/{param}") async def put_parameter( diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py index 04029190a..17c76be8c 100644 --- a/tests/demo/test_eiger.py +++ b/tests/demo/test_eiger.py @@ -1,10 +1,12 @@ import asyncio +import enum import httpx import pytest import pytest_asyncio from fastcs.attributes import AttrR, AttrRW +from fastcs.datatypes import Enum from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector from fastcs.demo.simulation.eiger import EigerParameter, create_eiger_sim_app from fastcs.util import ONCE @@ -40,7 +42,20 @@ async def test_hinted_attributes_are_introspected(detector: EigerDetector): assert detector.count_time.datatype.dtype is float assert isinstance(detector.state, AttrR) - assert detector.state.datatype.dtype is str + # ``state`` reports ``allowed_values``, so it is introspected as an enum whose + # members come from the device rather than as a bare string. + assert isinstance(detector.state.datatype, Enum) + assert detector.state.datatype.names == ["idle", "ready", "acquire"] + + +@pytest.mark.asyncio +async def test_enum_attribute_reads_as_member(detector: EigerDetector, sim: SimState): + sim["status"]["state"].value = "acquire" + await detector.state.bind_update_callback()() + + state = detector.state.get() + assert isinstance(state, enum.Enum) + assert state.value == "acquire" @pytest.mark.asyncio @@ -75,14 +90,14 @@ async def test_idle_derived_from_state(detector: EigerDetector, sim: SimState): assert detector.idle.get() is False # Poke the read-only ``state`` via the sim backdoor, then poll the attribute. - sim["status"]["state"].value = "idle" - await detector.state.bind_update_callback()() - assert detector.idle.get() is True - sim["status"]["state"].value = "acquire" await detector.state.bind_update_callback()() assert detector.idle.get() is False + sim["status"]["state"].value = "idle" + await detector.state.bind_update_callback()() + assert detector.idle.get() is True + @pytest.mark.asyncio async def test_read_only_params_poll_but_rw_read_once(detector: EigerDetector): From 83bcf674b7332ec5b906841eea9cdc55b6d42d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:22:36 +0000 Subject: [PATCH 25/36] demo: getter/setter-in-init temperature attr example Add temperature_attr.py: a small temperature controller with per-attribute IO (a fresh AttributeIO/AttributeIORef pair per attribute) wired directly in __init__ rather than shared class-body declarations, foreshadowing the AttrRW(getter=, setter=) constructor params landing in #392. Baseline against the current callback-IO API. Closes #404 --- src/fastcs/demo/temperature_attr.py | 79 +++++++++++++++++++++++++++++ tests/demo/test_temperature_attr.py | 48 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/fastcs/demo/temperature_attr.py create mode 100644 tests/demo/test_temperature_attr.py diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py new file mode 100644 index 000000000..84828b955 --- /dev/null +++ b/src/fastcs/demo/temperature_attr.py @@ -0,0 +1,79 @@ +"""Example 2 - getter/setter: per-attribute IO wired directly in ``__init__``. + +Baseline against the CURRENT callback-IO API (deliberately messy): each attribute +gets its own small ``AttributeIO``/``AttributeIORef`` pair, closing directly over the +command it queries/commands on the temperature sim, and attributes are assigned in +``__init__`` rather than declared in the class body. This foreshadows the +``AttrRW(getter=..., setter=...)`` constructor params landing in #392, without a +shared IO class dispatching by name (contrast with the composition example, +``controllers.py``, #390). +""" + +from dataclasses import dataclass + +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controllers import Controller +from fastcs.datatypes import Float + + +@dataclass +class TemperatureAttrSettings: + ip_settings: IPConnectionSettings + + +class RampRateIORef(AttributeIORef): + pass + + +class RampRateIO(AttributeIO[float, RampRateIORef]): + """IO for the ramp rate attribute only - a fresh instance per attribute.""" + + def __init__(self, connection: IPConnection): + super().__init__() + self._connection = connection + + async def update(self, attr: AttrR[float, RampRateIORef]) -> None: + response = await self._connection.send_query("R?\r\n") + await attr.update(attr.dtype(response.strip("\r\n"))) + + async def send(self, attr: AttrW[float, RampRateIORef], value: float) -> None: + await self._connection.send_command(f"R={attr.dtype(value)}\r\n") + + +class PowerIORef(AttributeIORef): + pass + + +class PowerIO(AttributeIO[float, PowerIORef]): + """IO for the power attribute only - a fresh instance per attribute.""" + + def __init__(self, connection: IPConnection): + super().__init__() + self._connection = connection + + async def update(self, attr: AttrR[float, PowerIORef]) -> None: + response = await self._connection.send_query("P?\r\n") + await attr.update(attr.dtype(response.strip("\r\n"))) + + +class TemperatureAttrController(Controller): + """A small temperature controller wired attribute-by-attribute in ``__init__``.""" + + def __init__(self, settings: TemperatureAttrSettings) -> None: + self.connection = IPConnection() + self._settings = settings + + super().__init__( + ios=[RampRateIO(self.connection), PowerIO(self.connection)] + ) + + self.ramp_rate = AttrRW(Float(), io_ref=RampRateIORef(update_period=0.2)) + self.power = AttrR(Float(), io_ref=PowerIORef(update_period=0.2)) + + async def connect(self) -> None: + await self.connection.connect(self._settings.ip_settings) + self._connected = True + + async def close(self) -> None: + await self.connection.close() diff --git a/tests/demo/test_temperature_attr.py b/tests/demo/test_temperature_attr.py new file mode 100644 index 000000000..b8a77dc50 --- /dev/null +++ b/tests/demo/test_temperature_attr.py @@ -0,0 +1,48 @@ +from unittest.mock import AsyncMock + +import pytest + +from fastcs.connections import IPConnectionSettings +from fastcs.demo.temperature_attr import ( + TemperatureAttrController, + TemperatureAttrSettings, +) + + +@pytest.fixture +def controller() -> TemperatureAttrController: + settings = TemperatureAttrSettings( + ip_settings=IPConnectionSettings(ip="localhost", port=25565) + ) + controller = TemperatureAttrController(settings) + controller.post_initialise() + return controller + + +@pytest.mark.asyncio +async def test_ramp_rate_read_from_device(controller: TemperatureAttrController): + controller.connection.send_query = AsyncMock(return_value="1.5\r\n") # type: ignore[method-assign] + + await controller.ramp_rate.bind_update_callback()() + + controller.connection.send_query.assert_awaited_once_with("R?\r\n") + assert controller.ramp_rate.get() == 1.5 + + +@pytest.mark.asyncio +async def test_ramp_rate_written_to_device(controller: TemperatureAttrController): + controller.connection.send_command = AsyncMock() # type: ignore[method-assign] + + await controller.ramp_rate.put(2.5) + + controller.connection.send_command.assert_awaited_once_with("R=2.5\r\n") + + +@pytest.mark.asyncio +async def test_power_read_from_device(controller: TemperatureAttrController): + controller.connection.send_query = AsyncMock(return_value="10.25\r\n") # type: ignore[method-assign] + + await controller.power.bind_update_callback()() + + controller.connection.send_query.assert_awaited_once_with("P?\r\n") + assert controller.power.get() == 10.25 From b68e74ad1cde373e5c6864e4f3f63cac6ccc6472 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:24:54 +0000 Subject: [PATCH 26/36] fix: ruff-format line-length nit --- src/fastcs/demo/temperature_attr.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py index 84828b955..2313ec3fa 100644 --- a/src/fastcs/demo/temperature_attr.py +++ b/src/fastcs/demo/temperature_attr.py @@ -64,9 +64,7 @@ def __init__(self, settings: TemperatureAttrSettings) -> None: self.connection = IPConnection() self._settings = settings - super().__init__( - ios=[RampRateIO(self.connection), PowerIO(self.connection)] - ) + super().__init__(ios=[RampRateIO(self.connection), PowerIO(self.connection)]) self.ramp_rate = AttrRW(Float(), io_ref=RampRateIORef(update_period=0.2)) self.power = AttrR(Float(), io_ref=PowerIORef(update_period=0.2)) From a8db570e1b1dfa32fd8bdbdf5df673ac38725a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:16:12 +0000 Subject: [PATCH 27/36] demo: reset _connected on close in temperature_attr controller Address CodeRabbit review comment: close() closed the socket but left _connected True, so a subsequent status check would still report connected. --- src/fastcs/demo/temperature_attr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py index 2313ec3fa..d351041e5 100644 --- a/src/fastcs/demo/temperature_attr.py +++ b/src/fastcs/demo/temperature_attr.py @@ -75,3 +75,4 @@ async def connect(self) -> None: async def close(self) -> None: await self.connection.close() + self._connected = False From 214aaf2c971420bf3b865a619a03dcabc1ddf948 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 23 Jul 2026 12:31:35 +0000 Subject: [PATCH 28/36] demo(#404): single callable-wrapping IO + protocol class (Thorlabs shape) Rewrites the getter/setter baseline to the intended shape: one generic TemperatureIO drives every attribute, and each TemperatureIORef carries the command-building callables (read_cmd/write_cmd) sourced from a single TemperatureProtocol class - mirroring fastcs-thorlabs-mff's MFFAttributeIO/MFFAttributeIORef/ThorlabsAPTProtocol. This is the honest precursor to #392's AttrRW(getter=, setter=): read_cmd/ write_cmd ARE the getter/setter, promoted onto the constructor when the IO/ref wrapper is deleted, while TemperatureProtocol survives unchanged. Replaces the previous per-attribute AttributeIO subclasses (RampRateIO/PowerIO), which hardcoded commands and foreshadowed nothing. Response parsing (float()) is inline in TemperatureIO.update rather than a response_handler callable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LgnovZ7FWY8YptwqiufTbX --- src/fastcs/demo/temperature_attr.py | 92 ++++++++++++++++++----------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py index d351041e5..a0511f292 100644 --- a/src/fastcs/demo/temperature_attr.py +++ b/src/fastcs/demo/temperature_attr.py @@ -1,14 +1,18 @@ -"""Example 2 - getter/setter: per-attribute IO wired directly in ``__init__``. - -Baseline against the CURRENT callback-IO API (deliberately messy): each attribute -gets its own small ``AttributeIO``/``AttributeIORef`` pair, closing directly over the -command it queries/commands on the temperature sim, and attributes are assigned in -``__init__`` rather than declared in the class body. This foreshadows the -``AttrRW(getter=..., setter=...)`` constructor params landing in #392, without a -shared IO class dispatching by name (contrast with the composition example, -``controllers.py``, #390). +"""Example 2 - getter/setter: per-attribute IO wired via callables in ``__init__``. + +Baseline against the CURRENT callback-IO API. A **single** generic IO class +(``TemperatureIO``) drives every attribute; the per-attribute behaviour lives in +each attribute's ``TemperatureIORef``, which just carries the command-building +callables (``read_cmd``/``write_cmd``) taken from a single ``TemperatureProtocol`` +class. This is the honest precursor to the ``AttrRW(getter=..., setter=...)`` +constructor params landing in #392: ``read_cmd``/``write_cmd`` *are* the +getter/setter, and #392 simply promotes them onto the constructor and deletes +this IO/ref wrapper, while ``TemperatureProtocol`` survives unchanged. Contrast +with the composition example (``controllers.py``, #390), whose shared IO instead +dispatches on a ``name`` string. """ +from collections.abc import Callable from dataclasses import dataclass from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW @@ -22,39 +26,47 @@ class TemperatureAttrSettings: ip_settings: IPConnectionSettings -class RampRateIORef(AttributeIORef): - pass +class TemperatureProtocol: + """The device wire protocol - one method per command, referenced by the IORefs. + Each getter returns the query string to send; each setter returns the command + string to send for a given value. These are exactly the callables #392 will + pass straight to ``AttrRW(getter=..., setter=...)``. + """ -class RampRateIO(AttributeIO[float, RampRateIORef]): - """IO for the ramp rate attribute only - a fresh instance per attribute.""" + def get_ramp_rate(self) -> str: + return "R?\r\n" - def __init__(self, connection: IPConnection): - super().__init__() - self._connection = connection + def set_ramp_rate(self, value: float) -> str: + return f"R={value}\r\n" - async def update(self, attr: AttrR[float, RampRateIORef]) -> None: - response = await self._connection.send_query("R?\r\n") - await attr.update(attr.dtype(response.strip("\r\n"))) + def get_power(self) -> str: + return "P?\r\n" - async def send(self, attr: AttrW[float, RampRateIORef], value: float) -> None: - await self._connection.send_command(f"R={attr.dtype(value)}\r\n") +@dataclass +class TemperatureIORef(AttributeIORef): + """Per-attribute IO spec: the command-building callables for one attribute.""" -class PowerIORef(AttributeIORef): - pass + read_cmd: Callable[[], str] + write_cmd: Callable[[float], str] | None = None -class PowerIO(AttributeIO[float, PowerIORef]): - """IO for the power attribute only - a fresh instance per attribute.""" +class TemperatureIO(AttributeIO[float, TemperatureIORef]): + """A single generic IO shared by every attribute; behaviour comes from the ref.""" def __init__(self, connection: IPConnection): super().__init__() self._connection = connection - async def update(self, attr: AttrR[float, PowerIORef]) -> None: - response = await self._connection.send_query("P?\r\n") - await attr.update(attr.dtype(response.strip("\r\n"))) + async def update(self, attr: AttrR[float, TemperatureIORef]) -> None: + response = await self._connection.send_query(attr.io_ref.read_cmd()) + await attr.update(float(response.strip("\r\n"))) + + async def send(self, attr: AttrW[float, TemperatureIORef], value: float) -> None: + if attr.io_ref.write_cmd is None: + raise TypeError(f"{attr} is read-only: no write_cmd on its io_ref") + await self._connection.send_command(attr.io_ref.write_cmd(value)) class TemperatureAttrController(Controller): @@ -63,11 +75,25 @@ class TemperatureAttrController(Controller): def __init__(self, settings: TemperatureAttrSettings) -> None: self.connection = IPConnection() self._settings = settings - - super().__init__(ios=[RampRateIO(self.connection), PowerIO(self.connection)]) - - self.ramp_rate = AttrRW(Float(), io_ref=RampRateIORef(update_period=0.2)) - self.power = AttrR(Float(), io_ref=PowerIORef(update_period=0.2)) + self._protocol = TemperatureProtocol() + + super().__init__(ios=[TemperatureIO(self.connection)]) + + self.ramp_rate = AttrRW( + Float(), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_ramp_rate, + write_cmd=self._protocol.set_ramp_rate, + update_period=0.2, + ), + ) + self.power = AttrR( + Float(), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_power, + update_period=0.2, + ), + ) async def connect(self) -> None: await self.connection.connect(self._settings.ip_settings) From 42bd98cdfac50b6794d7cab608d3d182dc1388d2 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Thu, 30 Jul 2026 16:13:38 +0000 Subject: [PATCH 29/36] test: drop unnecessary # type: ignore[method-assign] in temperature_attr tests The project type-checks with pyright (standard mode), which does not flag assigning an AsyncMock over a bound method here, and `method-assign` is a mypy error code pyright never emits. pyright src tests is clean without them. --- tests/demo/test_temperature_attr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/demo/test_temperature_attr.py b/tests/demo/test_temperature_attr.py index b8a77dc50..60e7fda9f 100644 --- a/tests/demo/test_temperature_attr.py +++ b/tests/demo/test_temperature_attr.py @@ -21,7 +21,7 @@ def controller() -> TemperatureAttrController: @pytest.mark.asyncio async def test_ramp_rate_read_from_device(controller: TemperatureAttrController): - controller.connection.send_query = AsyncMock(return_value="1.5\r\n") # type: ignore[method-assign] + controller.connection.send_query = AsyncMock(return_value="1.5\r\n") await controller.ramp_rate.bind_update_callback()() @@ -31,7 +31,7 @@ async def test_ramp_rate_read_from_device(controller: TemperatureAttrController) @pytest.mark.asyncio async def test_ramp_rate_written_to_device(controller: TemperatureAttrController): - controller.connection.send_command = AsyncMock() # type: ignore[method-assign] + controller.connection.send_command = AsyncMock() await controller.ramp_rate.put(2.5) @@ -40,7 +40,7 @@ async def test_ramp_rate_written_to_device(controller: TemperatureAttrController @pytest.mark.asyncio async def test_power_read_from_device(controller: TemperatureAttrController): - controller.connection.send_query = AsyncMock(return_value="10.25\r\n") # type: ignore[method-assign] + controller.connection.send_query = AsyncMock(return_value="10.25\r\n") await controller.power.bind_update_callback()() From ff9b4b7adbe7f276d837d1219524fce285ac778a Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Mon, 3 Aug 2026 10:59:05 +0000 Subject: [PATCH 30/36] demo(#404): convert existing temperature controller to getter/setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than adding a second temperature module, retarget #404 onto the existing `fastcs.demo.controllers` so there is one temperature demo. `TemperatureProtocol`/`TemperatureRampProtocol` carry one method per wire command, `TemperatureIORef` carries the `read_cmd`/`write_cmd` callables, and a single generic `TemperatureIO` just invokes them - the same shape as `fastcs-thorlabs-mff`, and the honest precursor to `AttrRW(getter=…, setter=…)` in #392. Attributes move from the class body into `__init__`, which is what lets each ramp bake its index into its own protocol instance instead of the IO dispatching on a `name` string plus suffix. Composition, `@scan` and `@command` are unchanged, so this module now covers both the getter/setter rung and the composition rung; the README ladder collapses accordingly. Co-Authored-By: Claude Opus 5 --- src/fastcs/demo/README.md | 18 ++- src/fastcs/demo/controllers.py | 200 +++++++++++++++++++++------- src/fastcs/demo/temperature_attr.py | 104 --------------- tests/demo/test_controllers.py | 84 ++++++++++++ tests/demo/test_temperature_attr.py | 48 ------- 5 files changed, 245 insertions(+), 209 deletions(-) delete mode 100644 src/fastcs/demo/temperature_attr.py delete mode 100644 tests/demo/test_temperature_attr.py diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md index 110f518a7..ff4ef631d 100644 --- a/src/fastcs/demo/README.md +++ b/src/fastcs/demo/README.md @@ -24,23 +24,22 @@ decorator) — there is no `io=` object and no `DataType`. | Module | Concept | Backend | Issue | |--------|---------|---------|-------| | `hello_world.py` | pure-soft `@attr` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | -| `temperature_attr.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`) | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404) | -| `controllers.py` | composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` (getter/setter IO) | temperature sim | [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | +| `controllers.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`), then composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404), [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | | `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each getter/setter from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | | `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | ## The four tutorials -Five modules, **four** tutorials (the old "reusable `io=` object" rung is gone — +Four modules, **four** tutorials (the old "reusable `io=` object" rung is gone — `io=` objects were replaced by getter/setter callables, so there is nothing to factor into): 1. **hello world** — `hello_world.py` (soft `@attr`). -2. **getter/setter** — `temperature_attr.py`; closes with *"when the shared - pattern is worth naming, reach for the declarative style →"*. -3. **declarative** — `temperature_scpi.py` (annotated `SCPIParam` + filler), - and this is where **composition + `@scan` + `@command`** are shown, walking - the full multi-ramp temperature controller (`controllers.py`, #390). +2. **getter/setter** — `controllers.py`; the full multi-ramp temperature + controller, so this is also where **composition + `@scan` + `@command`** + are shown (#390). Closes with *"when the shared pattern is worth naming, + reach for the declarative style →"*. +3. **declarative** — `temperature_scpi.py` (annotated `SCPIParam` + filler). 4. **introspectable** — `eiger.py`. Notes: @@ -67,8 +66,7 @@ Notes: ## Baselines vs framework PRs -`temperature_attr.py`, `controllers.py`, and `eiger.py` have current-API -baselines that can be written **now** (deliberately messy against the +`controllers.py` and `eiger.py` have current-API baselines that can be written **now** (deliberately messy against the pre-refactor API) and are cleaned up as each framework PR lands. `hello_world.py` and `temperature_scpi.py` need framework work first (`@attr` #397; `ControllerFiller` #394). See each issue's `Blocked by:` line. diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index b39937eee..f91b35e8a 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -1,8 +1,28 @@ +"""Example 2 - getter/setter: per-attribute IO wired via callables in ``__init__``. + +Baseline against the CURRENT callback-IO API. A **single** generic IO class +(``TemperatureIO``) drives every attribute; the per-attribute behaviour lives in +each attribute's ``TemperatureIORef``, which just carries the command-building +callables (``read_cmd``/``write_cmd``) taken from a protocol class with one method +per device command. This is the honest precursor to the +``AttrRW(getter=..., setter=...)`` constructor params landing in #392: +``read_cmd``/``write_cmd`` *are* the getter/setter, and #392 simply promotes them +onto the constructor and deletes this IO/ref wrapper, while the protocol classes +survive unchanged. + +Because the attributes are wired in ``__init__`` rather than the class body, each +one can close over per-instance state - which is what lets a ramp's index be baked +into its protocol instead of dispatched on at IO time. This module also carries the +composition and methods rungs: a ``ControllerVector`` of ``TemperatureRampController`` +sub-controllers, plus ``@scan`` and ``@command``. +""" + import asyncio import enum import json +from collections.abc import Callable from dataclasses import KW_ONLY, dataclass -from typing import TypeVar +from typing import Any, TypeVar import numpy as np @@ -27,35 +47,83 @@ class TemperatureControllerSettings: ip_settings: IPConnectionSettings +class TemperatureProtocol: + """The device wire protocol - one method per command, referenced by the IORefs. + + Each getter returns the query string to send; each setter returns the command + string to send for a given value. These are exactly the callables #392 will pass + straight to ``AttrRW(getter=..., setter=...)``. + """ + + def get_ramp_rate(self) -> str: + return "R?\r\n" + + def set_ramp_rate(self, value: float) -> str: + return f"R={value}\r\n" + + def get_power(self) -> str: + return "P?\r\n" + + def get_voltages(self) -> str: + return "V?\r\n" + + +class TemperatureRampProtocol: + """The wire protocol of a single ramp, whose commands are suffixed by its index. + + The index is baked into the instance, so every command is still a zero- or + one-argument callable that can be handed to an attribute as-is. + """ + + def __init__(self, index: int) -> None: + self.suffix = f"{index:02d}" + + def get_start(self) -> str: + return f"S{self.suffix}?\r\n" + + def set_start(self, value: int) -> str: + return f"S{self.suffix}={value}\r\n" + + def get_end(self) -> str: + return f"E{self.suffix}?\r\n" + + def set_end(self, value: int) -> str: + return f"E{self.suffix}={value}\r\n" + + def get_enabled(self) -> str: + return f"N{self.suffix}?\r\n" + + def set_enabled(self, value: OnOffEnum) -> str: + return f"N{self.suffix}={value}\r\n" + + def get_target(self) -> str: + return f"T{self.suffix}?\r\n" + + def get_actual(self) -> str: + return f"A{self.suffix}?\r\n" + + @dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str +class TemperatureIORef(AttributeIORef): + """Per-attribute IO spec: the command-building callables for one attribute.""" + + read_cmd: Callable[[], str] + write_cmd: Callable[[Any], str] | None = None _: KW_ONLY update_period: float | None = 0.2 -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection, suffix: str): +class TemperatureIO(AttributeIO[NumberT, TemperatureIORef]): + """A single generic IO shared by every attribute; behaviour comes from the ref.""" + + def __init__(self, connection: IPConnection): super().__init__() self._connection = connection - self.suffix = suffix - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}" - await self._connection.send_command(f"{command}\r\n") - self.log_event("Send command for attribute", topic=attr, command=command) - async def update( - self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef] - ) -> None: - query = f"{attr.io_ref.name}{self.suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - response = response.strip("\r\n") + async def update(self, attr: AttrR[NumberT, TemperatureIORef]) -> None: + query = attr.io_ref.read_cmd() + response = (await self._connection.send_query(query)).strip("\r\n") self.log_event( "Query for attribute", topic=attr, @@ -65,20 +133,37 @@ async def update( await attr.update(attr.dtype(response)) + async def send( + self, attr: AttrW[NumberT, TemperatureIORef], value: NumberT + ) -> None: + if attr.io_ref.write_cmd is None: + raise TypeError(f"{attr} is read-only: no write_cmd on its io_ref") + + command = attr.io_ref.write_cmd(value) + await self._connection.send_command(command) + self.log_event("Send command for attribute", topic=attr, command=command) -class TemperatureController(Controller): - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef(name="R")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef(name="P")) - voltages = AttrR(Waveform(np.int32, shape=(4,))) +class TemperatureController(Controller): def __init__(self, settings: TemperatureControllerSettings) -> None: self.connection = IPConnection() - self.suffix = "" - super().__init__( - ios=[TemperatureControllerAttributeIO(self.connection, self.suffix)] - ) - self._settings = settings + self._protocol = TemperatureProtocol() + + super().__init__(ios=[TemperatureIO(self.connection)]) + + self.ramp_rate = AttrRW( + Float(), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_ramp_rate, + write_cmd=self._protocol.set_ramp_rate, + ), + ) + self.power = AttrR( + Float(), io_ref=TemperatureIORef(read_cmd=self._protocol.get_power) + ) + # Updated by the update_voltages scan below, so no IO of its own + self.voltages = AttrR(Waveform(np.int32, shape=(4,))) self.ramps = ControllerVector( { @@ -112,10 +197,8 @@ async def close(self) -> None: @scan(0.1) async def update_voltages(self): - query = "V?" - voltages = json.loads( - (await self.connection.send_query(f"{query}\r\n")).strip("\r\n") - ) + query = self._protocol.get_voltages() + voltages = json.loads((await self.connection.send_query(query)).strip("\r\n")) await self.voltages.update(voltages) @@ -130,18 +213,41 @@ async def update_voltages(self): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW( - Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef(name="N") - ) - target = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="T")) - actual = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="A")) - voltage = AttrR(Float(prec=3)) - def __init__(self, index: int, conn: IPConnection) -> None: - suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(conn, suffix)] - ) + self._protocol = TemperatureRampProtocol(index) + + super().__init__(f"Ramp{self._protocol.suffix}", ios=[TemperatureIO(conn)]) + self.connection = conn + + self.start = AttrRW( + Int(), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_start, + write_cmd=self._protocol.set_start, + ), + ) + self.end = AttrRW( + Int(), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_end, + write_cmd=self._protocol.set_end, + ), + ) + self.enabled = AttrRW( + Enum(OnOffEnum), + io_ref=TemperatureIORef( + read_cmd=self._protocol.get_enabled, + write_cmd=self._protocol.set_enabled, + ), + ) + self.target = AttrR( + Float(prec=3), + io_ref=TemperatureIORef(read_cmd=self._protocol.get_target), + ) + self.actual = AttrR( + Float(prec=3), + io_ref=TemperatureIORef(read_cmd=self._protocol.get_actual), + ) + # Updated by the parent controller's update_voltages scan + self.voltage = AttrR(Float(prec=3)) diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py deleted file mode 100644 index a0511f292..000000000 --- a/src/fastcs/demo/temperature_attr.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Example 2 - getter/setter: per-attribute IO wired via callables in ``__init__``. - -Baseline against the CURRENT callback-IO API. A **single** generic IO class -(``TemperatureIO``) drives every attribute; the per-attribute behaviour lives in -each attribute's ``TemperatureIORef``, which just carries the command-building -callables (``read_cmd``/``write_cmd``) taken from a single ``TemperatureProtocol`` -class. This is the honest precursor to the ``AttrRW(getter=..., setter=...)`` -constructor params landing in #392: ``read_cmd``/``write_cmd`` *are* the -getter/setter, and #392 simply promotes them onto the constructor and deletes -this IO/ref wrapper, while ``TemperatureProtocol`` survives unchanged. Contrast -with the composition example (``controllers.py``, #390), whose shared IO instead -dispatches on a ``name`` string. -""" - -from collections.abc import Callable -from dataclasses import dataclass - -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW -from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controllers import Controller -from fastcs.datatypes import Float - - -@dataclass -class TemperatureAttrSettings: - ip_settings: IPConnectionSettings - - -class TemperatureProtocol: - """The device wire protocol - one method per command, referenced by the IORefs. - - Each getter returns the query string to send; each setter returns the command - string to send for a given value. These are exactly the callables #392 will - pass straight to ``AttrRW(getter=..., setter=...)``. - """ - - def get_ramp_rate(self) -> str: - return "R?\r\n" - - def set_ramp_rate(self, value: float) -> str: - return f"R={value}\r\n" - - def get_power(self) -> str: - return "P?\r\n" - - -@dataclass -class TemperatureIORef(AttributeIORef): - """Per-attribute IO spec: the command-building callables for one attribute.""" - - read_cmd: Callable[[], str] - write_cmd: Callable[[float], str] | None = None - - -class TemperatureIO(AttributeIO[float, TemperatureIORef]): - """A single generic IO shared by every attribute; behaviour comes from the ref.""" - - def __init__(self, connection: IPConnection): - super().__init__() - self._connection = connection - - async def update(self, attr: AttrR[float, TemperatureIORef]) -> None: - response = await self._connection.send_query(attr.io_ref.read_cmd()) - await attr.update(float(response.strip("\r\n"))) - - async def send(self, attr: AttrW[float, TemperatureIORef], value: float) -> None: - if attr.io_ref.write_cmd is None: - raise TypeError(f"{attr} is read-only: no write_cmd on its io_ref") - await self._connection.send_command(attr.io_ref.write_cmd(value)) - - -class TemperatureAttrController(Controller): - """A small temperature controller wired attribute-by-attribute in ``__init__``.""" - - def __init__(self, settings: TemperatureAttrSettings) -> None: - self.connection = IPConnection() - self._settings = settings - self._protocol = TemperatureProtocol() - - super().__init__(ios=[TemperatureIO(self.connection)]) - - self.ramp_rate = AttrRW( - Float(), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_ramp_rate, - write_cmd=self._protocol.set_ramp_rate, - update_period=0.2, - ), - ) - self.power = AttrR( - Float(), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_power, - update_period=0.2, - ), - ) - - async def connect(self) -> None: - await self.connection.connect(self._settings.ip_settings) - self._connected = True - - async def close(self) -> None: - await self.connection.close() - self._connected = False diff --git a/tests/demo/test_controllers.py b/tests/demo/test_controllers.py index bd7775bdf..039c0c3a0 100644 --- a/tests/demo/test_controllers.py +++ b/tests/demo/test_controllers.py @@ -24,6 +24,11 @@ def controller() -> TemperatureController: return controller +@pytest.fixture +def ramp_controller(controller: TemperatureController) -> TemperatureRampController: + return controller.ramps[1] + + def test_ramps_is_controller_vector(controller: TemperatureController): assert isinstance(controller.ramps, ControllerVector) assert list(controller.ramps) == [1, 2, 3, 4] @@ -32,6 +37,84 @@ def test_ramps_is_controller_vector(controller: TemperatureController): assert controller.ramps[index] is ramp +@pytest.mark.asyncio +async def test_ramp_rate_read_from_device(controller: TemperatureController): + controller.connection.send_query = AsyncMock(return_value="1.5\r\n") + + await controller.ramp_rate.bind_update_callback()() + + controller.connection.send_query.assert_awaited_once_with("R?\r\n") + assert controller.ramp_rate.get() == 1.5 + + +@pytest.mark.asyncio +async def test_ramp_rate_written_to_device(controller: TemperatureController): + controller.connection.send_command = AsyncMock() + + await controller.ramp_rate.put(2.5) + + controller.connection.send_command.assert_awaited_once_with("R=2.5\r\n") + + +@pytest.mark.asyncio +async def test_power_read_from_device(controller: TemperatureController): + controller.connection.send_query = AsyncMock(return_value="10.25\r\n") + + await controller.power.bind_update_callback()() + + controller.connection.send_query.assert_awaited_once_with("P?\r\n") + assert controller.power.get() == 10.25 + + +@pytest.mark.asyncio +async def test_ramp_start_read_from_device(ramp_controller: TemperatureRampController): + ramp_controller.connection.send_query = AsyncMock(return_value="7\r\n") + + await ramp_controller.start.bind_update_callback()() + + ramp_controller.connection.send_query.assert_awaited_once_with("S01?\r\n") + assert ramp_controller.start.get() == 7 + + +@pytest.mark.asyncio +async def test_ramp_end_written_to_device(ramp_controller: TemperatureRampController): + ramp_controller.connection.send_command = AsyncMock() + + await ramp_controller.end.put(42) + + ramp_controller.connection.send_command.assert_awaited_once_with("E01=42\r\n") + + +@pytest.mark.asyncio +async def test_ramp_enabled_written_to_device( + ramp_controller: TemperatureRampController, +): + ramp_controller.connection.send_command = AsyncMock() + + await ramp_controller.enabled.put(OnOffEnum.On) + + ramp_controller.connection.send_command.assert_awaited_once_with("N01=1\r\n") + + +@pytest.mark.asyncio +async def test_each_ramp_addresses_its_own_index(controller: TemperatureController): + controller.connection.send_command = AsyncMock() + + for index, ramp in controller.ramps.items(): + await ramp.start.put(index) + + assert [ + call.args[0] for call in controller.connection.send_command.await_args_list + ] == ["S01=1\r\n", "S02=2\r\n", "S03=3\r\n", "S04=4\r\n"] + + +@pytest.mark.asyncio +async def test_read_only_attribute_has_no_write_command( + ramp_controller: TemperatureRampController, +): + assert ramp_controller.target.io_ref.write_cmd is None + + @pytest.mark.asyncio async def test_cancel_all_disables_every_ramp(controller: TemperatureController): puts = {} @@ -53,6 +136,7 @@ async def test_update_voltages_updates_waveform_and_each_ramp( await controller.update_voltages() + controller.connection.send_query.assert_awaited_once_with("V?\r\n") np.testing.assert_array_equal( controller.voltages.get(), np.array([1, 2, 3, 4], dtype=np.int32) ) diff --git a/tests/demo/test_temperature_attr.py b/tests/demo/test_temperature_attr.py deleted file mode 100644 index 60e7fda9f..000000000 --- a/tests/demo/test_temperature_attr.py +++ /dev/null @@ -1,48 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest - -from fastcs.connections import IPConnectionSettings -from fastcs.demo.temperature_attr import ( - TemperatureAttrController, - TemperatureAttrSettings, -) - - -@pytest.fixture -def controller() -> TemperatureAttrController: - settings = TemperatureAttrSettings( - ip_settings=IPConnectionSettings(ip="localhost", port=25565) - ) - controller = TemperatureAttrController(settings) - controller.post_initialise() - return controller - - -@pytest.mark.asyncio -async def test_ramp_rate_read_from_device(controller: TemperatureAttrController): - controller.connection.send_query = AsyncMock(return_value="1.5\r\n") - - await controller.ramp_rate.bind_update_callback()() - - controller.connection.send_query.assert_awaited_once_with("R?\r\n") - assert controller.ramp_rate.get() == 1.5 - - -@pytest.mark.asyncio -async def test_ramp_rate_written_to_device(controller: TemperatureAttrController): - controller.connection.send_command = AsyncMock() - - await controller.ramp_rate.put(2.5) - - controller.connection.send_command.assert_awaited_once_with("R=2.5\r\n") - - -@pytest.mark.asyncio -async def test_power_read_from_device(controller: TemperatureAttrController): - controller.connection.send_query = AsyncMock(return_value="10.25\r\n") - - await controller.power.bind_update_callback()() - - controller.connection.send_query.assert_awaited_once_with("P?\r\n") - assert controller.power.get() == 10.25 From e73453b17cc878febe0f9d88fdbe5d550ce52887 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Mon, 3 Aug 2026 11:41:51 +0000 Subject: [PATCH 31/36] demo(#404): rename controllers.py to temperature_attr.py Match the naming of the other demo modules (hello_world.py, temperature_scpi.py, eiger.py), which are named for the device and the style they demonstrate rather than for the framework concept. Updates the importers: `fastcs.demo.__main__`, the test module, the README ladder and the docs nitpick-ignore entry. The launch `type:` in fastcs.yaml is derived from the top-level package, not the submodule, so `fastcs.TemperatureController` and the checked-in schema.json are unaffected (verified by regenerating the schema). Co-Authored-By: Claude Opus 5 --- docs/conf.py | 2 +- src/fastcs/demo/README.md | 6 +++--- src/fastcs/demo/__main__.py | 2 +- src/fastcs/demo/{controllers.py => temperature_attr.py} | 0 .../demo/{test_controllers.py => test_temperature_attr.py} | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename src/fastcs/demo/{controllers.py => temperature_attr.py} (100%) rename tests/demo/{test_controllers.py => test_temperature_attr.py} (99%) diff --git a/docs/conf.py b/docs/conf.py index 99b82e5cd..edfd712dd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -101,7 +101,7 @@ ("py:class", "fastcs.logging._graylog.GraylogStaticFields"), ("py:class", "fastcs.logging._graylog.GraylogEnvFields"), ("py:obj", "fastcs.control_system.build_controller_api"), - ("docutils", "fastcs.demo.controllers.TemperatureControllerSettings"), + ("docutils", "fastcs.demo.temperature_attr.TemperatureControllerSettings"), # TypeVar without docstrings still give warnings ("py:class", "strawberry.schema.schema.Schema"), ] diff --git a/src/fastcs/demo/README.md b/src/fastcs/demo/README.md index ff4ef631d..3865e7a2a 100644 --- a/src/fastcs/demo/README.md +++ b/src/fastcs/demo/README.md @@ -24,7 +24,7 @@ decorator) — there is no `io=` object and no `DataType`. | Module | Concept | Backend | Issue | |--------|---------|---------|-------| | `hello_world.py` | pure-soft `@attr` decorator over in-memory values | none (soft) | [#398](https://github.com/DiamondLightSource/fastcs/issues/398) | -| `controllers.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`), then composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404), [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | +| `temperature_attr.py` | `getter`/`setter` callables in `__init__` (`AttrRW(getter=…, setter=…)`), then composition & methods: sub-controllers / `ControllerVector`, `@scan`, `@command` | temperature sim | [#404](https://github.com/DiamondLightSource/fastcs/issues/404), [#390](https://github.com/DiamondLightSource/fastcs/issues/390) | | `temperature_scpi.py` (+ `scpi.py`) | declarative annotated attributes; `ControllerFiller` builds each getter/setter from **static** `SCPIParam` extras metadata | temperature sim | [#405](https://github.com/DiamondLightSource/fastcs/issues/405) | | `eiger.py` (+ `simulation/eiger.py`) | introspectable device: bare hints filled from a **runtime** REST parameter tree | Eiger REST sim | [#391](https://github.com/DiamondLightSource/fastcs/issues/391) | @@ -35,7 +35,7 @@ Four modules, **four** tutorials (the old "reusable `io=` object" rung is gone factor into): 1. **hello world** — `hello_world.py` (soft `@attr`). -2. **getter/setter** — `controllers.py`; the full multi-ramp temperature +2. **getter/setter** — `temperature_attr.py`; the full multi-ramp temperature controller, so this is also where **composition + `@scan` + `@command`** are shown (#390). Closes with *"when the shared pattern is worth naming, reach for the declarative style →"*. @@ -66,7 +66,7 @@ Notes: ## Baselines vs framework PRs -`controllers.py` and `eiger.py` have current-API baselines that can be written **now** (deliberately messy against the +`temperature_attr.py` and `eiger.py` have current-API baselines that can be written **now** (deliberately messy against the pre-refactor API) and are cleaned up as each framework PR lands. `hello_world.py` and `temperature_scpi.py` need framework work first (`@attr` #397; `ControllerFiller` #394). See each issue's `Blocked by:` line. diff --git a/src/fastcs/demo/__main__.py b/src/fastcs/demo/__main__.py index ff4548063..467be4f81 100644 --- a/src/fastcs/demo/__main__.py +++ b/src/fastcs/demo/__main__.py @@ -1,6 +1,6 @@ from fastcs import __version__ from fastcs.launch import launch -from .controllers import TemperatureController +from .temperature_attr import TemperatureController launch(TemperatureController, version=__version__) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/temperature_attr.py similarity index 100% rename from src/fastcs/demo/controllers.py rename to src/fastcs/demo/temperature_attr.py diff --git a/tests/demo/test_controllers.py b/tests/demo/test_temperature_attr.py similarity index 99% rename from tests/demo/test_controllers.py rename to tests/demo/test_temperature_attr.py index 039c0c3a0..437034209 100644 --- a/tests/demo/test_controllers.py +++ b/tests/demo/test_temperature_attr.py @@ -5,7 +5,7 @@ from fastcs.connections import IPConnectionSettings from fastcs.controllers import ControllerVector -from fastcs.demo.controllers import ( +from fastcs.demo.temperature_attr import ( OnOffEnum, TemperatureController, TemperatureControllerSettings, From f55580849d4b4815d083e396612c8f380cdd4915 Mon Sep 17 00:00:00 2001 From: "Tom C (DLS)" <101418278+coretl@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:05:25 +0100 Subject: [PATCH 32/36] attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO (#412) * attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO Per-attribute IO moves from a shared, ref-dispatched AttributeIO/ AttributeIORef pair onto plain getter/setter callables passed straight to AttrR/AttrW/AttrRW. Datatype is now optional on the constructors when it can be inferred from the getter/setter annotation. Runtime surface rename: get() -> .readback / .setpoint properties, no-arg update() -> poll() (does the getter read + caches + returns), update(value) stays as a pure cache-push (now also accepting Update[T]), put() -> set() (caches .setpoint, runs the setter, a non-None return updates .readback - the replacement for the old sync_setpoint-callback mechanism). Scheduling in Controller.create_api_and_tasks now polls getter-bearing attrs directly instead of going through an IO update-callback indirection. Removed: AttributeIO, AttributeIORef, ios=, _connect_attribute_ios, _validate_io, the second Attribute/AttrR/AttrW/AttrRW TypeVar. Migrates the demo composition example and all docs snippets that used the old io_ref= wiring. Deliberately out of scope for this PR (left for a follow-up): the DataType family / *Meta TypedDict replacement and the associated precision/Limits naming pass - the issue's own sizing note allows splitting the getter/setter half from the DataType-removal half. Closes #392 * docs: rewrite AttributeIO tutorial/how-to content for getter/setter API The docs build failed CI (fail-on-warning) because docs/tutorials/static-drivers.md's literalinclude emphasize-lines directives pointed at line numbers that no longer existed after the snippet rewrite. Fixing that surfaced the deeper issue: several tutorial and how-to pages narrated the removed AttributeIO/AttributeIORef pattern in prose, with code examples that no longer import. - Rewrite docs/tutorials/static-drivers.md and dynamic-drivers.md prose + literalinclude line references to match the getter/setter snippets. - Give docs/snippets/static15.py's TemperatureProtocol a Tracer base and thread `topic` through send_query, so the tutorial's per-attribute tracing walkthrough (enable_tracing on one attribute, see only its queries) still holds - a plain logger.trace call wouldn't respect per-attribute enable_tracing() at all. - Rewrite docs/how-to/update-attributes-from-device.md's four patterns (poll via getter, event-driven updates from a set, batched scan updates, scan-as-cache) for getter/setter. - Fix remaining AttributeIO/.get()/.put()/update_period mentions in docs/explanations/{transports,controllers,what-is-fastcs,datatypes}.md and docs/how-to/{table-waveform-data,wait-methods}.md. * attributes: address review - Polled, callback symmetry, setpoint mirroring Addresses the six review threads on #412. - Merge `getter` and `poll_period` into one argument via `Polled`: `AttrR(getter=Polled(protocol.get_temperature, period=0.1))`. A bare getter still means ONCE; `Polled(getter, period=None)` is on-demand only. - Symmetric callbacks: `add_on_update_callback` -> `add_readback_callback`, and a new `AttrW.add_setpoint_callback` alongside it. - `sync_setpoint` is gone. `Update` is now `readback`/`timestamp`/`setpoint`, where a `setpoint` of None leaves the cached setpoint alone. A bare value returned from a setter means both. - An AttrRW starts with no known setpoint; the first readback establishes it, which removes the need for transports to seed one. - Transports mirror the attribute's setpoint via `add_setpoint_callback` instead of tracking their own, so every transport agrees on it and CA no longer lags PVA. Recorded in ADR 0020; the one-shot seeding blocks in the CA and PVA transports are deleted. - `Attribute.__init__` is now strict about the datatype and the subclasses use cooperative `super().__init__()`: AttrR infers from the getter, AttrW from the setter, and AttrRW just passes both down the MRO, so the duplicated inference in AttrRW goes away. Also migrates the demo controllers that landed on refactor since this branch was cut (temperature_attr.py, eiger.py) off AttributeIO/AttributeIORef. Co-Authored-By: Claude Opus 5 * docs: drop unsupported "what most parameters want" claim about ONCE The ONCE default is settled in ADR 0014, but the ADR gives no rationale and this claim was not derived from anything - the repo's own examples lean the other way (49 Polled vs 0 bare getters across docs/snippets, 7 vs 0 in the temperature demo; only eiger.py's rw config branch uses a bare getter). Replace it with the criterion eiger.py actually applies: ONCE for values that change only when you change them, Polled for values the device changes itself. Co-Authored-By: Claude Opus 5 * attributes: add NotPolled, keep bare getter as read-once-at-connect Replaces the `Polled(getter, period=None)` spelling for "never scheduled" with an explicit `NotPolled(getter)`, so all three schedules read as what they do: AttrR(Float(), getter=self._get_config) # once, at connect AttrR(Float(), getter=Polled(self._get_reading, period=0.2)) # every 0.2s AttrR(String(), getter=NotPolled(self._get_label)) # never; poll() only AttrR(Float()) # soft, no getter `period` is keyword-only, so a period always says what it is. Both wrappers take an optional getter and bind one when called, which lets the same objects serve the declarative spelling in #397, where the getter arrives by decoration rather than as an argument: `@attr(Polled(0.5), units="V")`. A bare getter stays read-once-at-connect rather than becoming unpolled. A bare `@attr` has to resolve to some schedule (ADR 18), so a constructor that refused to default while the decorator defaulted would reintroduce the asymmetry these wrappers exist to remove - and of the two candidate defaults, once-at-connect is the one that fails safe. Unpolled-by-default leaves an AttrRW at the datatype default, which under ADR 20 never establishes a setpoint either, so every transport would show 0/""/False until someone wrote to it. Amends ADR 0014 (schedule travels with the getter; records the three options considered) and ADR 0018 (`@attr` takes a schedule positionally instead of a `poll_period=` kwarg the constructor no longer has, with a table pairing the two spellings). Fixes the stale `poll_period=` examples in ADR 0013. Co-Authored-By: Claude Opus 5 * docs: rewrite ADR 0014 to describe the design as built The refactor-branch ADRs are unreleased, so 0014 is rewritten in place rather than accumulating amendments. Every decision and justification is kept; only stale text describing intermediate designs is dropped. - The schedule-travels-with-the-getter amendment is folded into the Decision as its own section, with the table pairing the procedural and declarative spellings and the three candidate defaults with the reason bare-means-once was chosen. - New section documenting Update as built (readback/timestamp/setpoint), why setpoint is there, and that severity belongs to ADR 16 rather than being described here as if it already existed. - Runtime surface table gains update_setpoint() and the two symmetric callback registrars, with a pointer to ADR 20 for why transports must not track their own setpoint. - Question 6 (is the setpoint echo visible across transports?) is answered rather than deferred: the CA-lags-PVA follow-up it left open is closed by ADR 20. Added question 7 for the poll_period merge. - Migration section now covers what happens to a ref's update_period, and Consequences names the one non-mechanical migration step: a driver relying on the old update_period=None default gains a connect-time read. Co-Authored-By: Claude Opus 5 * demo: protocol methods do their own IO, so they are the getters and setters The protocol class only built command strings, so an adapter (TemperatureLink) had to bind them to a connection before an attribute could call them - which put a layer between the protocol and the attribute and undersold the point of getter/setter. Make the protocol what a manufacturer would actually ship: one async method per command, doing its own IO and returning an annotated type. Those methods are then handed straight over: self.ramp_rate = AttrRW( getter=Polled(protocol.get_ramp_rate, period=0.2), setter=protocol.set_ramp_rate, ) TemperatureLink is deleted. Because the methods annotate their types, the datatype is now inferred for every attribute except target/actual, which state Float(prec=3) to carry display precision an annotation cannot - which shows both halves of the inference rule in one file. The enum infers its members from get_enabled's `-> OnOffEnum` return type. TemperatureRampProtocol becomes a subclass carrying a per-index suffix rather than a separate class, since the query/command plumbing is now shared. The wire format is unchanged - all existing tests pass untouched. Typing get_voltages caught a latent bug the untyped json.loads had hidden: it fed a list to a Waveform attribute rather than an ndarray. Co-Authored-By: Claude Opus 5 * test: await the cancelled server task in the CA initial-value test The test requested cancellation but never awaited it, so the server's sockets and event loop were still live when the forked child exited. At interpreter shutdown that emits ResourceWarnings, which `filterwarnings = "error"` turns into a failure - reported against whichever test the collection lands on. It surfaced on 3.12 only, and not locally, so this is a fix for CI rather than something reproducible here; the redundant `except Exception: raise` is dropped while touching the block. Unrelated to the rest of this PR. Co-Authored-By: Claude Opus 5 * Revert "test: await the cancelled server task in the CA initial-value test" This reverts commit 8992ab28. The change was speculative and did not fix the 3.12 failure - the leaked loop and sockets come from somewhere else, so the commit message's claim was wrong and the change is unrelated churn in this PR. The awaiting-a-cancelled-task point still stands on its own merits and is worth doing separately, alongside finding the actual leak. Co-Authored-By: Claude Opus 5 * TEMP: enable tracemalloc in the tests env to locate the leaked event loop Diagnostic only - to be reverted before merge. Co-Authored-By: Claude Opus 5 * Revert "TEMP: enable tracemalloc in the tests env to locate the leaked event loop" This reverts commit f5f80c73d2e26348cd4943c58f8030ee3e5c4e6c. * test: stop out-of-band warnings failing whichever test is running PytestUnraisableExceptionWarning and PytestUnhandledThreadExceptionWarning are raised for events that happen outside any test - an exception during garbage collection, or in a non-main thread - and pytest attributes them to whichever test is running at the time. With `filterwarnings = "error"` that fails an unrelated test. This suite leaves objects alive in its subprocess and multiprocessing fixtures (run_ioc_as_subprocess's forkserver and Queues, the tickit Popen in test_docs_snippets), so a ResourceWarning is emitted whenever they are collected. Which test it lands on varied by Python version and by run: 3.12 was failing on tests/transports/epics/ca/test_initial_value.py, which neither touches those fixtures nor fails in isolation. Confirmed by running CI with PYTHONTRACEMALLOC=25, which adds the allocation traceback to each warning: they point at test_docs_snippets.py's Popen and conftest.py's run_ioc_as_subprocess/p4p_subprocess/softioc_subprocess. With tracemalloc's extra overhead all three Python versions failed, confirming the leak is universal and only masked by timing. Both warnings are downgraded to "report but do not fail" rather than silenced, so real leaks stay visible in the output; everything else still errors. The underlying fixture leaks are worth fixing separately - this only stops them failing unrelated tests. Co-Authored-By: Claude Opus 5 * attributes: address review - document `always`, tighten test assertions Review follow-ups from @shihab-dls: - `AttrR.add_readback_callback`: document the `always` parameter. Its effect was only inferrable by reading `update()`, which decides whether to call a callback by comparing the new value with the cached one. - `AttrRW.set`: drop the "sanctioned replacement for the old private setpoint-echo mechanism" sentence. That is ADR material (0014/0020), not something a caller of `set()` needs; the docstring now just says what a returned value means. - `tests/test_attributes.py`: match on the exception message, not just the type. Applied to the two `pytest.raises` calls raised in review and to the four others in the same file, so the file is consistent - happy to narrow it back to the two if that is too wide. - `tests/example_p4p_ioc.py`: give the manual PVA test IOC some IO again. It lost all of it when `AttributeIO` went, so nothing in it exercised the replacement. `ChildController.clamped` is a getter/setter pair over an in-memory value whose setter clamps to 0..100 and returns what it accepted, which exercises both halves of ADR 0020 by hand: the getter seeds the setpoint at connect, and the clamped return drives readback and setpoint together. `test_ioc`'s PVI assertion is updated for the new PV. Co-Authored-By: Claude Opus 5 * pva: build PVs during connect() so the seeded setpoint is served An AttrRW seeds its setpoint from its first readback (ADR 0020), and that readback arrives from the initial poll, which FastCS.serve() runs before it gathers the transports' serve() coroutines. P4PIOC built its PVs inside run(), i.e. inside serve(), so the setpoint callback did not exist yet when the seed happened: attribute.setpoint held the seeded value but a pvget on the setpoint PV returned the datatype default. EpicsCAIOC already builds its records in __init__ (during connect()) and was unaffected. Build the providers in P4PIOC.__init__ instead, leaving run() to serve them. parse_attributes had no awaits, so it becomes a plain function. Also addresses two review points on the tests: hoist the repeated expected message in test_datatype_required_when_not_inferable into a variable, and assert test_set_setter_exception_is_caught_and_logged actually logs the setter's exception rather than only implying it. Refs #392 * tests: add synced setpoint check in CA and PVA system tests --------- Co-authored-by: Claude Co-authored-by: Shihab Suliman --- docs/explanations/controllers.md | 7 +- docs/explanations/datatypes.md | 8 +- ...-procedural-split-and-controller-filler.md | 2 +- .../decisions/0014-attribute-io-rw-rework.md | 154 ++++-- .../decisions/0018-attr-decorator-sugar.md | 24 +- .../0020-transport-setpoint-mirroring.md | 69 +++ docs/explanations/transports.md | 73 ++- docs/explanations/what-is-fastcs.md | 5 +- docs/how-to/table-waveform-data.md | 2 +- docs/how-to/update-attributes-from-device.md | 220 ++++----- docs/how-to/wait-methods.md | 2 +- docs/snippets/dynamic.py | 92 ++-- docs/snippets/static07.py | 34 +- docs/snippets/static08.py | 48 +- docs/snippets/static09.py | 66 +-- docs/snippets/static10.py | 92 ++-- docs/snippets/static11.py | 104 +++-- docs/snippets/static12.py | 116 +++-- docs/snippets/static13.py | 118 +++-- docs/snippets/static14.py | 119 +++-- docs/snippets/static15.py | 124 +++-- docs/tutorials/dynamic-drivers.md | 18 +- docs/tutorials/static-drivers.md | 158 +++---- pyproject.toml | 16 +- src/fastcs/attributes/__init__.py | 10 +- src/fastcs/attributes/_infer_datatype.py | 52 +++ src/fastcs/attributes/attr_r.py | 194 +++++--- src/fastcs/attributes/attr_rw.py | 90 +++- src/fastcs/attributes/attr_w.py | 159 ++++--- src/fastcs/attributes/attribute.py | 27 +- src/fastcs/attributes/attribute_io.py | 60 --- src/fastcs/attributes/attribute_io_ref.py | 26 -- src/fastcs/attributes/update.py | 28 ++ src/fastcs/controllers/base_controller.py | 44 +- src/fastcs/controllers/controller.py | 25 +- src/fastcs/controllers/controller_vector.py | 6 +- src/fastcs/demo/eiger.py | 64 ++- src/fastcs/demo/temperature_attr.py | 226 ++++----- src/fastcs/transports/epics/ca/ioc.py | 9 +- src/fastcs/transports/epics/ca/util.py | 4 +- .../transports/epics/pva/_pv_handlers.py | 11 +- src/fastcs/transports/epics/pva/ioc.py | 17 +- src/fastcs/transports/graphql/graphql.py | 4 +- src/fastcs/transports/rest/rest.py | 4 +- src/fastcs/transports/tango/dsr.py | 4 +- tests/assertable_controller.py | 88 ++-- tests/conftest.py | 8 +- tests/demo/test_eiger.py | 36 +- tests/demo/test_temperature_attr.py | 41 +- tests/example_p4p_ioc.py | 65 ++- tests/example_softioc.py | 14 +- tests/test_attribute_logging.py | 6 +- tests/test_attributes.py | 439 +++++++++--------- tests/test_control_system.py | 51 +- tests/test_multi_controller.py | 2 +- tests/transports/epics/ca/test_softioc.py | 34 +- .../epics/ca/test_softioc_system.py | 5 + tests/transports/epics/pva/test_p4p.py | 60 +++ tests/transports/graphQL/test_graphql.py | 7 +- tests/transports/rest/test_rest.py | 24 +- tests/transports/tango/test_dsr.py | 8 +- 61 files changed, 2047 insertions(+), 1576 deletions(-) create mode 100644 docs/explanations/decisions/0020-transport-setpoint-mirroring.md create mode 100644 src/fastcs/attributes/_infer_datatype.py delete mode 100644 src/fastcs/attributes/attribute_io.py delete mode 100644 src/fastcs/attributes/attribute_io_ref.py create mode 100644 src/fastcs/attributes/update.py diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md index 2378144d2..3d18defd5 100644 --- a/docs/explanations/controllers.md +++ b/docs/explanations/controllers.md @@ -28,8 +28,9 @@ lifecycle, if required. ### Scan task behaviour When used as the root controller, FastCS collects all `@scan` methods and readable -attributes with `update_period` set, across the whole controller hierarchy to be run as -background tasks by FastCS. Scan tasks are gated on the `_connected` flag: if a scan +attributes whose `getter` is wrapped in `Polled`, across the whole controller +hierarchy, to be run as background tasks by FastCS. Scan tasks are gated on the +`_connected` flag: if a scan raises an exception, `_connected` is set to `False` and tasks pause until `reconnect` sets it back to `True`. @@ -154,7 +155,7 @@ distinct components with different types or roles. `BaseController` is the common base class for both `Controller` and `ControllerVector`. It handles the creation and validation of attributes, scan methods, command methods, and -sub controllers, including type hint introspection and IO connection. +sub controllers, including type hint introspection. `BaseController` is public for use in **type hints only**. It should not be subclassed directly when implementing a device driver. Use `Controller` or `ControllerVector` diff --git a/docs/explanations/datatypes.md b/docs/explanations/datatypes.md index c614deb1d..fb1d81740 100644 --- a/docs/explanations/datatypes.md +++ b/docs/explanations/datatypes.md @@ -175,7 +175,7 @@ float_type.validate(42) # Returns 42.0 (int -> float) Validation runs automatically when: 1. **Attribute update**: `await attr.update(value)` validates before storing -2. **Put request**: `await attr.put(value)` validates before sending to device +2. **Set request**: `await attr.set(value)` validates before sending to device 3. **Initial value**: Values passed to `initial_value` are validated on creation ```python @@ -188,9 +188,9 @@ attr = AttrRW(Int(min=0, max=10), initial_value=5) await attr.update(7) # OK await attr.update(15) # Raises ValueError -# Puts are validated -await attr.put(3) # OK -await attr.put(-1) # Raises ValueError +# Sets are validated +await attr.set(3) # OK +await attr.set(-1) # Raises ValueError ``` ## Transport Handling diff --git a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md index 1b802a5be..f9c2ce865 100644 --- a/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md +++ b/docs/explanations/decisions/0013-declarative-procedural-split-and-controller-filler.md @@ -107,7 +107,7 @@ class TemperatureRampController(Controller): await conn.send_command(f"S{suffix}={value}\r\n") # datatype int is inferred from get_start's return annotation - self.start = AttrRW(getter=get_start, setter=set_start, poll_period=0.2) + self.start = AttrRW(getter=Polled(get_start, period=0.2), setter=set_start) ``` **Declarative hint + filler** — the value is *promised* by a hint; the diff --git a/docs/explanations/decisions/0014-attribute-io-rw-rework.md b/docs/explanations/decisions/0014-attribute-io-rw-rework.md index 0b939687d..5911b3ec8 100644 --- a/docs/explanations/decisions/0014-attribute-io-rw-rework.md +++ b/docs/explanations/decisions/0014-attribute-io-rw-rework.md @@ -1,10 +1,10 @@ # 14. Per-Attribute IO as getter/setter Callables -Date: 2026-07-20 +Date: 2026-07-20 (revised 2026-08-03, after the #412 review) **Related:** [Issue #388](https://github.com/DiamondLightSource/fastcs/issues/388), [ADR 9](0009-handler-to-attribute-io-pattern.md), [ADR 12](0012-attribute-io-naming-convention.md), -[ADR 18](0018-attr-decorator-sugar.md) +[ADR 18](0018-attr-decorator-sugar.md), [ADR 20](0020-transport-setpoint-mirroring.md) ## Status @@ -69,14 +69,8 @@ decorator ([ADR 18](0018-attr-decorator-sugar.md)): - The **setter** returns `None | T | Update[T]`: `None` = fire-and-forget (readback catches up on the next poll / the setpoint cache); a returned value is the device's *accepted* value (a clamp or echo) and updates the readback + - the `AttrW` setpoint cache immediately — the sanctioned replacement for + the setpoint cache immediately — the sanctioned replacement for `fastcs-secop`'s private `_call_sync_setpoint_callbacks`. -- `Update[T]` carries `value: T`, `timestamp: float | None` (epoch seconds; - `None` ⇒ framework stamps receive-time), and `severity: Severity = OK` (the - decision-10b severity enum, see - [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)). It is - used for both the getter return and a value-returning setter — this is how - device-native timestamps/severity reach `attr.update()`. - **Datatype is optional when a getter/setter is given** — inferred from the getter's return annotation (or the setter's parameter), unwrapping `Update[T]` to `T`, so `AttrR(getter=g)` yields `AttrR[float]` with no restated type @@ -85,10 +79,6 @@ decorator ([ADR 18](0018-attr-decorator-sugar.md)): `Unpack[*Meta]` static check keys off the inferred return type. Not inferable (`-> Any`, an unannotated lambda) ⇒ the positional datatype is required (fail-fast at construction). -- `poll_period` is a read-side kwarg: `ONCE` = read once at connect (the - default when a getter is given); a float = poll at that rate; `None` = - **on-demand only** (read when a client asks, never auto-polled). **No - getter** = soft, value pushed via `attr.update()` from a `@scan`/callback. - Soft is now simply the *absence* of a getter/setter (`AttrRW(float)` self-wires setpoint→readback as before, the analogue of ophyd-async's `soft_signal_rw`); the old `io=None` sentinel is gone. @@ -116,10 +106,81 @@ class TemperatureRampController(Controller): # datatype float inferred from get_ramp_rate's return annotation self.ramp_rate = AttrRW( - getter=get_ramp_rate, setter=set_ramp_rate, units="deg", poll_period=0.2 + getter=Polled(get_ramp_rate, period=0.2), + setter=set_ramp_rate, + units="deg", ) ``` +### The reading schedule travels with the getter + +There is no `poll_period` constructor argument. A getter carries its own +schedule, so the two cannot drift apart and the pair can be passed around as +one value: + +```python +self.config = AttrR(float, getter=self._get_config) # once, at connect +self.reading = AttrR(float, getter=Polled(self._get_reading, period=0.2)) # every 0.2s +self.label = AttrR(str, getter=NotPolled(self._get_label)) # never; poll() only +self.computed = AttrR(float) # soft, no getter +``` + +`Polled` and `NotPolled` take an optional getter and bind one when called, so +the same objects serve the declarative spelling in +[ADR 18](0018-attr-decorator-sugar.md) — where the getter arrives by decoration +and there is no argument to wrap — giving one vocabulary across both: + +| Schedule | Procedural | Declarative | +|---|---|---| +| Once, at connect | `AttrR(t, getter=g)` | `@attr(units="V")` | +| Every 0.5s | `AttrR(t, getter=Polled(g, period=0.5))` | `@attr(Polled(0.5), units="V")` | +| Never; `poll()` only | `AttrR(t, getter=NotPolled(g))` | `@attr(NotPolled(), units="V")` | + +**A bare getter means "read once, at connect"**, not "never read". Three +defaults were considered: + +1. *Bare = once* (chosen). Fails safe: an attribute always shows a real value, + and polling is opted into per attribute rather than being something you must + remember to switch off. +2. *Bare = never read.* Restores the pre-refactor `AttributeIORef.update_period + = None` default and makes all scheduling explicit — but fails **silently**: + an unpolled `AttrRW` sits at the datatype default and, under + [ADR 20](0020-transport-setpoint-mirroring.md), never establishes a setpoint + either, so every transport shows `0`/`""`/`False` until someone writes to it. +3. *No default; always require a wrapper.* Rejected because ADR 18 promises a + bare `@attr`, which must resolve to some schedule. A constructor that refused + to default while the decorator defaulted would reintroduce the asymmetry + these wrappers exist to remove. + +`ONCE` (`float("inf")`) survives internally as what `poll_period` reports for +the bare case, but a driver author never spells it: `Polled(getter, +period=ONCE)` would read as a contradiction, and the once-only case is the one +with no wrapper at all. `NotPolled(g)` is distinct from having no getter — the +former is still readable via `await attr.poll()` and by transports on demand, +the latter has nothing to read. + +### `Update[T]` + +`Update` is what a getter or setter returns when a bare value is not enough: + +```python +@dataclass +class Update(Generic[T]): + readback: T + timestamp: float | None = None # None ⇒ framework stamps receive-time + setpoint: T | None = None # None ⇒ leave the cached setpoint alone +``` + +- `readback` is the value, named for the cache it feeds. +- `setpoint` is how a device that reports its own setpoint drives one, and how + a setter distinguishes "the device clamped the value it will *report*" from + "the device clamped what I *asked for*". A **bare** value returned from a + setter means both — it is equivalent to `Update(readback=v, setpoint=v)`. +- `severity` is **not** on `Update` yet; native timestamps and the severity + enum are [ADR 16](0016-setpoint-cache-timestamps-and-controller-runner.md)'s + scope and land with it. `timestamp` is accepted here so the field ordering is + settled, but is not yet persisted. + ### Runtime surface The old `get()` / `update(value)` / `put(value)` method trio is renamed and @@ -132,7 +193,10 @@ are legible from the member set: | `.setpoint` | property (sync) | — | ✓ | ✓ | no (cached) | | `poll()` | async method | ✓ | — | ✓ | **yes** (getter) | | `update(value)` | async method | ✓ | — | ✓ | no (cache push) | +| `update_setpoint(value)` | async method | — | ✓ | ✓ | no (cache push) | | `set(value)` | async method | — | ✓ | ✓ | **yes** (setter) | +| `add_readback_callback()` | method | ✓ | — | ✓ | no | +| `add_setpoint_callback()` | method | — | ✓ | ✓ | no | - **`.readback` / `.setpoint` replace `.value`.** Two explicitly-named cached properties instead of one whose meaning shifted per class. Each class exposes @@ -145,18 +209,24 @@ are legible from the member set: - **`poll()` replaces the no-arg `update()`; `update_period` → `poll_period`.** `poll()` does a live getter read, caches it, and **returns** the value (so an on-demand read is `await attr.poll()`, mirroring ophyd's live `get_value()`); - `poll_period` (`ONCE` / float / `None`) is only the *schedule* the framework - calls it on. This deletes the `set_update_callback` / `bind_update_callback` - plumbing — the getter lives on the attr and `poll()` calls it. + `poll_period` is now a read-only property reporting the schedule resolved from + the getter's wrapper, not a constructor argument. This deletes the + `set_update_callback` / `bind_update_callback` plumbing — the getter lives on + the attr and `poll()` calls it. - **`update(value)` is now purely a cache push** — a `value` or `Update[T]` from a `@scan`/subscription — with no device IO and no `None` sentinel. + `update_setpoint(value)` is its setpoint-side counterpart. - **`set(value)` replaces `put()`** (the bluesky/ophyd verb): it caches - `.setpoint` immediately (decision 10a), then runs the setter; the setter's - `T | Update[T]` return feeds `.readback` via `update()`. The old - `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` are gone. Caching - `.setpoint` first is an *attribute-cache* guarantee only; *when a remote - client sees it* is transport-dependent and differs between CA and PVA — see - the Questions resolved below. + `.setpoint` immediately (decision 10a) and publishes it to the setpoint + callbacks, then runs the setter; the setter's return feeds `.readback` via + `update()`. The old `sync_setpoint=` kwarg and `_call_sync_setpoint_callbacks` + are gone. +- **The two callback registrars are symmetric.** `add_readback_callback()` + (formerly `add_on_update_callback()`) and `add_setpoint_callback()` are how + transports publish each cache. Transports must not track a setpoint of their + own — see [ADR 20](0020-transport-setpoint-mirroring.md), which also removes + the per-transport "seeding" of a setpoint display by making the first readback + on an `AttrRW` establish the setpoint. So `poll()`/`set()` touch the device; `.readback`/`.setpoint`/`update()` do not. `Attribute` also loses its second generic parameter — @@ -263,9 +333,14 @@ def temp_io(conn: IPConnection, name: str): return getter, setter get_ramp, set_ramp = temp_io(conn, "R") -self.ramp_rate = AttrRW(getter=get_ramp, setter=set_ramp, poll_period=0.2) +self.ramp_rate = AttrRW(getter=Polled(get_ramp, period=0.2), setter=set_ramp) ``` +The old ref's `update_period=0.2` becomes the `Polled(..., period=0.2)` wrapper; +a ref that left `update_period` at its `None` default becomes `NotPolled(...)` +if it really should never be read, or a bare getter if a connect-time read was +what it wanted. + `fastcs-catio`'s three-IO-per-controller pattern becomes per-attribute callables with no registry needed at all. `fastcs-secop`'s private `_call_sync_setpoint_callbacks` call is replaced by a value-returning setter. @@ -287,8 +362,12 @@ callables with no registry needed at all. `fastcs-secop`'s private - The IO no longer has a place to hang per-attribute metadata that `fastcs-catio` used to read off `attribute.io_ref`; `attr.meta` and the attribute's own attributes replace that access. +- Drivers that relied on the old ref default of `update_period=None` change + behaviour if they migrate to a bare getter: they gain a connect-time read. + This is intended (see the three options above) but is the one migration step + that is not purely mechanical. -## Questions resolved in review (#402) +## Questions resolved in review (#402, #412) 1. **What replaces the `io=` object and the `ReadIO`/`WriteIO`/`ReadWriteIO` hierarchy?** Plain `getter`/`setter` callables on the constructors. The IO @@ -300,7 +379,7 @@ callables with no registry needed at all. `fastcs-secop`'s private 3. **What is the public replacement for `fastcs-secop`'s `_call_sync_setpoint_callbacks`?** A `setter` returning `T | Update[T]` *is* the sanctioned setpoint echo — the returned value updates the readback and - the `AttrW` setpoint cache. + the setpoint cache. 4. **Are there `CallbackReadIO`/`CallbackWriteIO` classes in core?** No. The one-off callback case folds into `@attr` / `AttrR(getter=…)` ([ADR 18](0018-attr-decorator-sugar.md)); the same spelling covers the @@ -309,12 +388,17 @@ callables with no registry needed at all. `fastcs-secop`'s private Through `attr.meta` and the attribute's own public members, replacing `fastcs-catio`'s `attribute.io_ref` access. 6. **Is the setpoint echo a cross-transport "instantly visible" guarantee?** - (@Tom-Willemsen / @shihab-dls.) No — caching `.setpoint` before running the - setter is an *attribute-cache* guarantee (and the sanctioned secop echo); - whether a *remote client* sees it immediately is transport-dependent. **PVA** - posts the setpoint as soon as it is written, then the record may later go - into alarm if the setter rejects it. **CA** posts the PV update only *after* - the update callback (where alarms are set) completes, so a long-running - setter delays the CA-visible setpoint until the send returns. Realigning CA - to PVA's post-before-send ordering is a **transport-layer** follow-up, - tracked separately and not gating this rework. + (@Tom-Willemsen / @shihab-dls.) It is now. Caching `.setpoint` before running + the setter was originally an *attribute-cache* guarantee only, with the + remote-client view left transport-dependent: **PVA** posted the setpoint as + soon as it was written, whereas **CA** posted only *after* the update callback + completed, so a long-running setter delayed the CA-visible setpoint. The + follow-up this left open is closed by + [ADR 20](0020-transport-setpoint-mirroring.md): every transport now mirrors + the attribute's setpoint through `add_setpoint_callback()`, which fires + before the setter runs, so CA and PVA agree and the ordering is a property of + the attribute rather than of each transport. +7. **Should `poll_period` be a second constructor argument?** No — merged into + the getter as `Polled`/`NotPolled` wrappers, so a getter and its schedule are + one value and the same vocabulary works in the `@attr` decorator, where there + is no getter argument to pair it with. diff --git a/docs/explanations/decisions/0018-attr-decorator-sugar.md b/docs/explanations/decisions/0018-attr-decorator-sugar.md index 923364a25..196775cbf 100644 --- a/docs/explanations/decisions/0018-attr-decorator-sugar.md +++ b/docs/explanations/decisions/0018-attr-decorator-sugar.md @@ -56,7 +56,7 @@ writer. ```python class PowerSupply(Controller): - @attr(units="V", poll_period=0.5) # datatype inferred from -> float + @attr(Polled(0.5), units="V") # datatype inferred from -> float async def voltage(self) -> float: """Output voltage.""" return await self._conn.query("V?") @@ -75,10 +75,24 @@ class PowerSupply(Controller): as *better* than PyTango's `dtype=` kwarg since it's one real annotation, checked statically. - `@attr` comes in two forms: bare `@attr` and parameterised - `@attr(precision=3, units="V", poll_period=0.5)`; the keyword arguments map - onto the same `*Meta` fields (typed with `Unpack[…Meta]`, validated against - the getter's return type) and the `poll_period` read-side kwarg of - `AttrR`/`AttrRW` — sugar over that mechanism, not a parallel one. + `@attr(Polled(0.5), precision=3, units="V")`. The keyword arguments map onto + the same `*Meta` fields (typed with `Unpack[…Meta]`, validated against the + getter's return type). The optional leading positional is a **schedule** - + the same `Polled`/`NotPolled` objects the procedural form wraps its getter in + ([ADR 14](0014-attribute-io-rw-rework.md), amendment 2026-08-03) - so the two + spellings share one vocabulary rather than the decorator taking a + `poll_period=` kwarg the constructor no longer has. Sugar over that + mechanism, not a parallel one. +- **Bare `@attr` means the same as a bare `getter=`**: read once, when the + controller connects. This symmetry is why the constructor keeps a default + instead of demanding a wrapper - a bare decorator has to resolve to some + schedule, so both sides default to the same safe one: + + | Schedule | Procedural | Declarative | + |---|---|---| + | Once, at connect | `AttrR(t, getter=g)` | `@attr(units="V")` | + | Every 0.5s | `AttrR(t, getter=Polled(g, period=0.5))` | `@attr(Polled(0.5), units="V")` | + | Never; `poll()` only | `AttrR(t, getter=NotPolled(g))` | `@attr(NotPolled(), units="V")` | - `@attr`'s `.setter` decorator mirrors `@property`/`@x.setter`, giving the read+write pair a single logical name (`voltage`) with two decorated methods. There is **no dedicated write-only decorator** — a paired-getter-less `AttrW` diff --git a/docs/explanations/decisions/0020-transport-setpoint-mirroring.md b/docs/explanations/decisions/0020-transport-setpoint-mirroring.md new file mode 100644 index 000000000..5d83520f3 --- /dev/null +++ b/docs/explanations/decisions/0020-transport-setpoint-mirroring.md @@ -0,0 +1,69 @@ +# 20. Transports mirror the attribute setpoint rather than tracking their own + +Date: 2026-08-03 + +## Status + +Accepted + +## Context + +An `AttrRW` has two values a transport must present: the readback (what the device +reports) and the setpoint (what was last asked of it). Readbacks were already +published by callback - the attribute calls back on every change and each transport +posts it - but setpoints were not. + +Instead, each transport maintained its own setpoint display and updated it directly +in its write path, on the assumption that the only thing that could change a +setpoint was a write arriving through that same transport. That assumption is wrong +as soon as there is more than one transport, or a device that reports its own +setpoint. + +It also left a visible gap at startup. A setpoint display starts at the datatype's +default, which is usually not the device's actual value, so each transport grew a +one-shot "seeding" hack: subscribe to the *readback* callback, and the first time a +value arrives, copy it into the setpoint display and unsubscribe. Two transports had +near-identical copies of this, and it only worked for `AttrRW` (a pure `AttrW` has no +readback to seed from). + +The two EPICS transports had also drifted apart on ordering. PVA posted the setpoint +as soon as the put arrived, before the setter ran; CA posted it only after the update +callback completed, so a slow setter left the CA setpoint stale for the duration of +the write. + +## Decision + +The attribute owns the setpoint, and transports mirror it. + +- `AttrW` gains `add_setpoint_callback()`, the setpoint-side counterpart of + `AttrR.add_readback_callback()` (renamed from `add_on_update_callback()` for the + symmetry). Every transport registers one and posts whatever it is given. +- `AttrW.update_setpoint()` caches a setpoint and fires those callbacks. `set()` + calls it before running the setter, so the requested value is visible immediately; + a value returned by the setter goes through it again, so a clamped or rejected + value replaces it. +- A getter or setter can also drive the setpoint by returning + `Update(readback=..., setpoint=...)` - the mechanism for a device that reports its + own setpoint. `setpoint=None` (the default) leaves the cached setpoint alone. +- Seeding is gone. An `AttrRW` starts with no known setpoint, and the first readback + to arrive - from a poll, a scan, or anything else calling `update()` - establishes + it. Subsequent readbacks do not, so a readback that disagrees with the setpoint + does not silently rewrite what the user asked for. + +Transports must not update their own setpoint display directly in their write path. + +## Consequences + +Every transport shows the same setpoint, whichever transport was written through, and +CA and PVA now agree on when it appears: at the start of the write, before the setter +runs. That is the behaviour PVA already had, and it is the one that gives GUIs +immediate feedback. + +Attribution is lost at the transport layer - a client cannot tell from the setpoint +alone which transport originated a write. This is deliberate: consistency between +transports is worth more than attribution, and attribution is recoverable from the +logs, which record the originating transport for every `set()`. + +The one-shot seeding blocks in the CA and PVA transports are deleted, along with the +`isinstance(attribute, AttrR)` checks that guarded them, since the mechanism now works +for a pure `AttrW` too. diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md index bf34a2fcc..5a99c9ff5 100644 --- a/docs/explanations/transports.md +++ b/docs/explanations/transports.md @@ -98,14 +98,15 @@ layer. | Callback | Registered with | Triggered By | Direction | Purpose | |----------|-----------------|--------------|-----------|---------| -| On Update | `add_on_update_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when attribute value changes | -| Sync Setpoint | `add_sync_setpoint_callback()` | `attr.put(value, sync_setpoint=True)` | Publish ↑ | Update transport's setpoint display without device communication | +| Readback | `add_readback_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when the attribute's readback changes | +| Setpoint | `add_setpoint_callback()` | `attr.set(value)` | Publish ↑ | Update protocol representation when the attribute's setpoint changes | | Update Datatype | `add_update_datatype_callback()` | `datatype` property changes | Publish ↑ | Update protocol metadata when datatype changes | -| Put | `attr.put(value)` | Transport receives user input | Put ↓ | Forward write requests from protocol to attribute | +| Set | `attr.set(value)` | Transport receives user input | Set ↓ | Forward write requests from protocol to attribute | -### On Update Callbacks +### Readback Callbacks -Use `add_on_update_callback()` to update the protocol layer when an attribute's value changes. +Use `add_readback_callback()` to update the protocol layer when an attribute's +readback changes. ```python def create_read(name, attribute): @@ -114,7 +115,7 @@ def create_read(name, attribute): async def update_protocol_value(value): protocol_read.post(value) - attribute.add_on_update_callback(update_protocol_value) + attribute.add_readback_callback(update_protocol_value) ``` The callback receives the new value and should update the protocol-specific @@ -129,7 +130,7 @@ Use `add_update_datatype_callback()` to update protocol metadata when an attribu def create_read(name, attribute): ... - attribute.add_on_update_callback(update_protocol_value) + attribute.add_readback_callback(update_protocol_value) def update_protocol_metadata(datatype: DataType): protocol_read.set_units(datatype.units) @@ -140,49 +141,47 @@ def create_read(name, attribute): The callback receives the new `DataType` instance and should update the protocol's metadata representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). -### Put +### Setpoint Callbacks -When the transport receives a write request from the protocol, call `await -attribute.put(value)` to forward it to the attribute. This triggers validation and -propagates the value to the device via the IO layer. The transport should also update -its own setpoint display directly rather than relying on the sync setpoint callback -being called. +Use `add_setpoint_callback()` to update the protocol layer when an attribute's +setpoint changes. A transport must **not** update its own setpoint display directly - +it registers a callback and lets the attribute drive it, so that every transport +agrees on the setpoint however it was changed (see +[](./decisions/0020-transport-setpoint-mirroring)). ```python def create_write(name, attribute): protocol_setpoint = Protocol(name) - async def handle_write(value): + async def update_protocol_setpoint(value): protocol_setpoint.post(value) - await attribute.put(value) -``` - -### Sync Setpoint Callbacks -Use `add_sync_setpoint_callback()` to update the protocol layer's setpoint -representation when the transport receives a write request. This is called when -`AttrW.put` is called with `sync_setpoint=True`. - -Each transport is responsible for updating its own setpoint display while actioning the -change and should not rely on its sync setpoint callback being called by the attribute, -nor should it call `AttrW.put` with `sync_setpoint=True`. Setpoints should not be synced -between transports in this case - this is intentional to show which transport the change -came from. + async def handle_write(value): + await attribute.set(value) -```python -def create_write(name, attribute): - ... + attribute.add_setpoint_callback(update_protocol_setpoint) +``` - async def update_setpoint_display(value): - protocol_setpoint.post(value) +The callback fires when: - attribute.add_sync_setpoint_callback(update_setpoint_display) -``` +- a write arrives through *any* transport - `set()` caches the requested value and + publishes it before running the setter, so the display updates immediately rather + than waiting for a slow device; +- the setter returns a value, which replaces it with the device's accepted or clamped + value; +- a getter or setter returns `Update(readback=..., setpoint=...)`, for a device that + reports its own setpoint; +- the first readback arrives on an `AttrRW` that has never been written. An `AttrRW` + starts with no known setpoint, so this is what stops a setpoint display sitting at + the datatype's default until someone writes to it. No seeding is required in the + transport. -Sync setpoint callbacks are used in specific cases: +### Set -- When an attribute delegates to other attributes that actually communicate with the device -- During the first update of an `AttrRW`, to initialize the setpoint with the first readback value +When the transport receives a write request from the protocol, call `await +attribute.set(value)` to forward it to the attribute. This triggers validation, caches +the value as the attribute's `.setpoint` (firing the setpoint callbacks above), and (if +the attribute has one) runs its `setter` to propagate the value to the device. ## Commands diff --git a/docs/explanations/what-is-fastcs.md b/docs/explanations/what-is-fastcs.md index 501a54284..da10059e9 100644 --- a/docs/explanations/what-is-fastcs.md +++ b/docs/explanations/what-is-fastcs.md @@ -22,9 +22,8 @@ without modification. A FastCS application has three layers: **Controller** - a Python class that models the device. It holds attributes and -commands, implements connection logic, and creates periodic polling tasks. The -controller can create `AttributeIO`s to handle `update` and `send` operations between -attributes and the device. +commands, implements connection logic, and creates periodic polling tasks. Attributes +take `getter`/`setter` callables that read and write values on the device. **Attributes and commands** - typed values (`AttrR`, `AttrW`, `AttrRW`) and callable actions (`@command`) declared on the controller. Attributes represent the device's diff --git a/docs/how-to/table-waveform-data.md b/docs/how-to/table-waveform-data.md index d7a7572bc..8a768604f 100644 --- a/docs/how-to/table-waveform-data.md +++ b/docs/how-to/table-waveform-data.md @@ -129,7 +129,7 @@ await controller.channel_data.update(data) ```python # Get the table -table = controller.results.get() +table = controller.results.readback # Access by column name names = table["name"] diff --git a/docs/how-to/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md index 895eb5f2a..f76fbc04e 100644 --- a/docs/how-to/update-attributes-from-device.md +++ b/docs/how-to/update-attributes-from-device.md @@ -3,129 +3,121 @@ There are different patterns for pushing values from a device into attributes to suit different use cases. Choose the pattern that fits how the device API delivers data. -## Update Tasks via `AttributeIO.update` +## Poll via a Getter -Use this pattern when each attribute maps to an independent request to the device. The -`AttributeIO.update` method is called periodically as a background task, once per -attribute, at the rate set by `update_period` in the attribute's `AttributeIORef`. +Use this pattern when each attribute maps to an independent request to the device. Give +the attribute a `getter` wrapped in `Polled` and FastCS will call it periodically as a +background task, at the period given. -Define an `AttributeIORef` with an `update_period` and implement `AttributeIO.update` -to query the device and call `attr.update` with the result: +Write a getter that queries the device and returns the value - the framework caches it +and calls any update callbacks; there's no need to call `attr.update` yourself: ```python -from dataclasses import KW_ONLY, dataclass - -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, NotPolled, Polled from fastcs.controllers import Controller from fastcs.datatypes import Float, String -@dataclass -class MyDeviceIORef(AttributeIORef): - register: str - _: KW_ONLY - update_period: float | None = 0.5 - - -class MyDeviceIO(AttributeIO[float, MyDeviceIORef]): +class MyController(Controller): def __init__(self, connection): - super().__init__() self._connection = connection + super().__init__() - async def update(self, attr: AttrR[float, MyDeviceIORef]): - response = await self._connection.send_query(f"{attr.io_ref.register}?\r\n") - await attr.update(float(response.strip())) + self.temperature = AttrR( + Float(), getter=Polled(self._get_temperature, period=0.5) + ) + self.setpoint = AttrRW( + Float(), + getter=Polled(self._get_setpoint, period=1.0), + setter=self._set_setpoint, + ) + self.label = AttrR(String(), getter=NotPolled(self._get_label)) - async def send(self, attr: AttrW[float, MyDeviceIORef], value: float): - await self._connection.send_command(f"{attr.io_ref.register}={value}\r\n") + async def _get_temperature(self) -> float: + response = await self._connection.send_query("T?\r\n") + return float(response.strip()) + async def _get_setpoint(self) -> float: + response = await self._connection.send_query("S?\r\n") + return float(response.strip()) -class MyController(Controller): - temperature = AttrR(Float(), io_ref=MyDeviceIORef("T")) - setpoint = AttrRW(Float(), io_ref=MyDeviceIORef("S", update_period=1.0)) - label = AttrR(String(), io_ref=MyDeviceIORef("L", update_period=None)) + async def _set_setpoint(self, value: float) -> None: + await self._connection.send_command(f"S={value}\r\n") - def __init__(self, connection): - super().__init__(ios=[MyDeviceIO(connection)]) + async def _get_label(self) -> str: + response = await self._connection.send_query("L?\r\n") + return response.strip() ``` -Setting `update_period` to: +How the getter is passed decides when it is called: -- A positive `float` — polls at that interval in seconds. -- `None` — no automatic updates; the attribute value is only set explicitly (e.g. from a - scan method or subscription callback). -- `ONCE` (imported from `fastcs`) — called once on startup and not again. +- A bare getter (`getter=self._get_label`) — the `ONCE` schedule: read when the + controller connects, and not again. Use it for values that only change because + you changed them, such as writable configuration the device holds for you. +- `Polled(getter, period=0.5)` — polls at that interval in seconds. Use it for + values the device changes on its own, such as readings and status. +- `NotPolled(getter)` — never read on a schedule; the attribute value is only set + explicitly (e.g. from a scan method or subscription callback), or read on demand + via `await attr.poll()`. This differs from giving no getter at all, which leaves + nothing to read on demand. -## Initial Read with Event-Driven Updates from Puts +`ONCE` is the default when a getter is given, so polling is opted into per +attribute rather than being something you have to remember to switch off. -Use this pattern when attributes need their initial value read on startup, but subsequent -updates arrive as side-effects of write operations rather than on a fixed poll cycle. -This is common for devices that echo back related parameter values in their response to a -set command. +## Initial Read with Event-Driven Updates from Sets -Set `update_period=ONCE` on the `AttributeIORef` so that `AttributeIO.update` is called -once when the application starts. Then, in `AttributeIO.send`, parse the device's -response to the put and call `attr.update` on any attributes whose values have changed: +Use this pattern when attributes need their initial value read on startup, but +subsequent updates arrive as side-effects of write operations rather than on a fixed +poll cycle. This is common for devices that echo back related parameter values in their +response to a set command. -```python -from collections.abc import Awaitable, Callable -from dataclasses import KW_ONLY, dataclass +Pass the getter bare, without `Polled`, so it runs once on startup and not again. +Then, in the setter, parse the device's response and call `.update()` directly on any +sibling attributes whose values have changed: -from fastcs import ONCE -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +```python +from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller from fastcs.datatypes import Float -@dataclass -class MyDeviceIORef(AttributeIORef): - register: str - _: KW_ONLY - update_period: float | None = ONCE - - - -PutResponseCallback = Callable[[str], Awaitable[None]] - - -class MyDeviceIO(AttributeIO[float, MyDeviceIORef]): - def __init__(self, connection, on_put_response: PutResponseCallback | None = None): - super().__init__() +class MyController(Controller): + def __init__(self, connection): self._connection = connection - self._on_put_response = on_put_response - - async def update(self, attr: AttrR[float, MyDeviceIORef]): - response = await self._connection.send_query(f"{attr.io_ref.register}?\r\n") - await attr.update(float(response.strip())) + super().__init__() - async def send(self, attr: AttrW[float, MyDeviceIORef], value: float): - # Device responds with a snapshot of all current values after a set - response = await self._connection.send_query( - f"{attr.io_ref.register}={value}\r\n" + self.setpoint = AttrRW( + Float(), getter=self._get_setpoint, setter=self._set_setpoint ) - if self._on_put_response is not None: - await self._on_put_response(response) + self.actual_temperature = AttrR(Float(), getter=self._get_actual_temperature) + self.power = AttrR(Float(), getter=self._get_power) + self.status = AttrR(Float(), getter=self._get_status) + async def _get_setpoint(self) -> float: + return float((await self._connection.send_query("S?\r\n")).strip()) -class MyController(Controller): - setpoint = AttrRW(Float(), io_ref=MyDeviceIORef("S")) - actual_temperature = AttrR(Float(), io_ref=MyDeviceIORef("T")) - power = AttrR(Float(), io_ref=MyDeviceIORef("P")) - status = AttrR(Float(), io_ref=MyDeviceIORef("X")) - - def __init__(self, connection): - super().__init__(ios=[MyDeviceIO(connection, self._handle_put_response)]) - - async def _handle_put_response(self, response: str) -> None: + async def _set_setpoint(self, value: float) -> None: + # Device responds with a snapshot of all current values after a set + response = await self._connection.send_query(f"S={value}\r\n") actual, power, status = response.strip().split(",") await self.actual_temperature.update(float(actual)) await self.power.update(float(power)) await self.status.update(float(status)) + + async def _get_actual_temperature(self) -> float: + return float((await self._connection.send_query("T?\r\n")).strip()) + + async def _get_power(self) -> float: + return float((await self._connection.send_query("P?\r\n")).strip()) + + async def _get_status(self) -> float: + return float((await self._connection.send_query("X?\r\n")).strip()) ``` -Attributes that are updated as side-effects of puts can still carry `update_period=ONCE` -so they also get their initial value on startup. Set `update_period=None` instead if the -device response to the put is the only source of truth and no initial poll is needed. +Attributes that are updated as a side-effect of a set can still take a bare getter, +so they also get their initial value on startup. Use `NotPolled(getter)` instead if +the device's response to the set is the only source of truth and no initial poll is +needed. ## Batched Updates via a Scan Method @@ -133,8 +125,9 @@ Use this pattern when the device returns values for multiple attributes in a sin response. A `@scan` method runs periodically on the controller and distributes the results by calling `attr.update` directly on each attribute. -Attributes that are updated this way do not need an `io_ref` with an `update_period` -because the scan method drives the updates rather than individual IO tasks. +Attributes that are updated this way do not need a `getter` at all, because +the scan method drives the updates directly, rather than each attribute polling +independently. ```python import json @@ -146,7 +139,7 @@ from fastcs.methods import scan class ChannelController(Controller): - voltage = AttrR(Float()) # No io_ref — updated by parent scan method + voltage = AttrR(Float()) # No getter — updated by parent scan method def __init__(self, index: int, connection): super().__init__(f"Ch{index:02d}") @@ -178,66 +171,53 @@ class MultiChannelController(Controller): The scan period (here `0.1` seconds) sets how often the batched query runs. Scans that raise an exception will pause and wait for `reconnect()` to be called before resuming. -### Scan as a cache for `AttributeIO.update` +### Scan as a cache for getters When there are many attributes to update from a batched response, calling `attr.update` for each one inside the scan method becomes verbose. Instead, the scan can populate a -cache on the `AttributeIO`, and each attribute's regular update task reads from that -cache rather than querying the device while the device is still only queried once per -cycle. +shared cache, and each attribute's own getter (polled independently) reads from that +cache rather than querying the device - the device is still only queried once per cycle. ```python import json -from dataclasses import KW_ONLY, dataclass -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR from fastcs.controllers import Controller from fastcs.datatypes import Float from fastcs.methods import scan -@dataclass -class ChannelIORef(AttributeIORef): - index: int - _: KW_ONLY - update_period: float | None = 0.1 - - -class ChannelIO(AttributeIO[float, ChannelIORef]): - def __init__(self): - super().__init__() - self._cache: dict[int, float] = {} - - def update_cache(self, values: dict[int, float]) -> None: - self._cache = values +class ChannelController(Controller): + def __init__(self, index: int, cache: dict[int, float]): + self._index = index + self._cache = cache + super().__init__(f"Ch{index:02d}") - async def update(self, attr: AttrR[float, ChannelIORef]): - cached = self._cache.get(attr.io_ref.index) - if cached is not None: - await attr.update(cached) + self.voltage = AttrR(Float(), getter=Polled(self._get_voltage, period=0.1)) - -class ChannelController(Controller): - def __init__(self, index: int, io: ChannelIO): - super().__init__(f"Ch{index:02d}", ios=[io]) - self.voltage = AttrR(Float(), io_ref=ChannelIORef(index)) + async def _get_voltage(self) -> float: + return self._cache.get(self._index, 0.0) class MultiChannelController(Controller): def __init__(self, channel_count: int, connection): self._connection = connection - self._channel_io = ChannelIO() + self._cache: dict[int, float] = {} super().__init__() + self._channels: list[ChannelController] = [] for i in range(channel_count): - self.add_sub_controller(f"Ch{i:02d}", ChannelController(i, self._channel_io)) + ch = ChannelController(i, self._cache) + self._channels.append(ch) + self.add_sub_controller(f"Ch{i:02d}", ch) @scan(0.1) async def fetch_voltages(self): voltages = json.loads( (await self._connection.send_query("V?\r\n")).strip() ) - self._channel_io.update_cache(dict(enumerate(map(float, voltages)))) + self._cache.clear() + self._cache.update(enumerate(map(float, voltages))) ``` ## Subscription Callbacks diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md index d61fb8bec..e9ef09b94 100644 --- a/docs/how-to/wait-methods.md +++ b/docs/how-to/wait-methods.md @@ -20,7 +20,7 @@ class MotorController(Controller): @command() async def move_and_wait(self): """Move to target and wait until we arrive.""" - target = self.target.get() + target = self.target.readback # Start the move (implementation depends on your device) await self._start_move(target) diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py index 7dde6dfe5..9a5cc4f55 100644 --- a/docs/snippets/dynamic.py +++ b/docs/snippets/dynamic.py @@ -1,23 +1,31 @@ import json -from dataclasses import KW_ONLY, dataclass from typing import Any, Literal, TypeVar from pydantic import BaseModel, ConfigDict, ValidationError -from fastcs.attributes import ( - Attribute, - AttributeIO, - AttributeIORef, - AttrR, - AttrRW, - AttrW, -) +from fastcs.attributes import Attribute, AttrR, AttrRW from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Bool, DataType, Float, Int, String from fastcs.launch import FastCS from fastcs.transports.epics.ca import EpicsCATransport +ValueT = TypeVar("ValueT") + + +class TemperatureProtocol: + def __init__(self, connection: IPConnection): + self._connection = connection + + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}={dtype(value)}" # type: ignore[call-arg] + await self._connection.send_command(f"{command}\r\n") + + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class TemperatureControllerParameter(BaseModel): model_config = ConfigDict(extra="forbid") @@ -39,7 +47,9 @@ def fastcs_datatype(self) -> DataType: return String() -def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]: +def create_attributes( + parameters: dict[str, Any], protocol: TemperatureProtocol +) -> dict[str, Attribute]: attributes: dict[str, Attribute] = {} for name, parameter in parameters.items(): name = name.replace(" ", "_").lower() @@ -50,46 +60,23 @@ def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]: print(f"Failed to validate parameter '{parameter}'\n{e}") continue - io_ref = TemperatureControllerAttributeIORef(parameter.command) + datatype = parameter.fastcs_datatype + command = parameter.command + + async def getter(command=command, dtype=datatype.dtype): + return await protocol.send_query(command, dtype) + match parameter.access_mode: case "r": - attributes[name] = AttrR(parameter.fastcs_datatype, io_ref=io_ref) + attributes[name] = AttrR(datatype, getter=getter) case "rw": - attributes[name] = AttrRW(parameter.fastcs_datatype, io_ref=io_ref) - - return attributes - -NumberT = TypeVar("NumberT", int, float) + async def setter(value, command=command, dtype=datatype.dtype): + await protocol.send_command(command, value, dtype) + attributes[name] = AttrRW(datatype, getter=getter, setter=setter) -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - - self._connection = connection - - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}={attr.dtype(value)}" - await self._connection.send_command(f"{command}\r\n") + return attributes class TemperatureRampController(Controller): @@ -97,13 +84,16 @@ def __init__( self, index: int, parameters: dict[str, TemperatureControllerParameter], - io: TemperatureControllerAttributeIO, + protocol: TemperatureProtocol, ): self._parameters = parameters - super().__init__(f"Ramp{index}", ios=[io]) + self._protocol = protocol + super().__init__(f"Ramp{index}") async def initialise(self): - for name, attribute in create_attributes(self._parameters).items(): + for name, attribute in create_attributes( + self._parameters, self._protocol + ).items(): self.add_attribute(name, attribute) @@ -111,9 +101,9 @@ class TemperatureController(Controller): def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - self._io = TemperatureControllerAttributeIO(self._connection) - super().__init__(ios=[self._io]) + super().__init__() async def connect(self): await self._connection.connect(self._ip_settings) @@ -125,12 +115,12 @@ async def initialise(self): ramps_api = api.pop("Ramps") - for name, attribute in create_attributes(api).items(): + for name, attribute in create_attributes(api, self._protocol).items(): self.add_attribute(name, attribute) for idx, ramp_parameters in enumerate(ramps_api): ramp_controller = TemperatureRampController( - idx + 1, ramp_parameters, self._io + idx + 1, ramp_parameters, self._protocol ) await ramp_controller.initialise() self.add_sub_controller(f"Ramp{idx + 1:02d}", ramp_controller) diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py index cac5549d3..3bd0e04f3 100644 --- a/docs/snippets/static07.py +++ b/docs/snippets/static07.py @@ -1,8 +1,6 @@ -from dataclasses import dataclass from pathlib import Path -from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import String @@ -10,35 +8,19 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) - - -@dataclass -class IDAttributeIORef(AttributeIORef): - update_period: float | None = 0.2 - - -class IDAttributeIO(AttributeIO[NumberT, IDAttributeIORef]): - def __init__(self, connection: IPConnection): - super().__init__() - - self._connection = connection - - async def update(self, attr: AttrR[NumberT, IDAttributeIORef]): - response = await self._connection.send_query("ID?\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=IDAttributeIORef()) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() - super().__init__(ios=[IDAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + + async def _get_device_id(self) -> str: + response = await self._connection.send_query("ID?\r\n") + return response.strip("\r\n") async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py index 5382fa3c9..f2483679e 100644 --- a/docs/snippets/static08.py +++ b/docs/snippets/static08.py @@ -1,8 +1,7 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, String @@ -10,41 +9,40 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - +class TemperatureProtocol: + def __init__(self, connection: IPConnection, suffix: str = ""): self._connection = connection + self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] + await self._connection.send_command(f"{command}\r\n") - await attr.update(attr.dtype(value)) + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py index dc5bb9d54..1a75ea678 100644 --- a/docs/snippets/static09.py +++ b/docs/snippets/static09.py @@ -1,8 +1,7 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, String @@ -10,48 +9,51 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): - def __init__(self, connection: IPConnection): - super().__init__() - +class TemperatureProtocol: + def __init__(self, connection: IPConnection, suffix: str = ""): self._connection = connection + self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) - def __init__(self, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) + + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py index 24f6d523d..3c02c402e 100644 --- a/docs/snippets/static10.py +++ b/docs/snippets/static10.py @@ -1,8 +1,7 @@ -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Float, Int, String @@ -10,60 +9,69 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] -class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) +class TemperatureRampController(Controller): def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, ) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) + + super().__init__() - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -71,6 +79,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py index e07ed5a41..6dd4359f0 100644 --- a/docs/snippets/static11.py +++ b/docs/snippets/static11.py @@ -1,9 +1,8 @@ import enum -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -11,38 +10,23 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -50,27 +34,61 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, + ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, ) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -78,6 +96,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py index 9f1f2af14..818f0337c 100644 --- a/docs/snippets/static12.py +++ b/docs/snippets/static12.py @@ -1,10 +1,9 @@ import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -13,38 +12,23 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -52,30 +36,70 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(Float()) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -83,6 +107,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) diff --git a/docs/snippets/static13.py b/docs/snippets/static13.py index b2036c66f..9313a873a 100644 --- a/docs/snippets/static13.py +++ b/docs/snippets/static13.py @@ -1,11 +1,10 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -14,38 +13,23 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -53,30 +37,70 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(Float()) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -84,6 +108,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -98,7 +134,7 @@ async def update_voltages(self): @command() async def disable_all(self) -> None: for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/snippets/static14.py b/docs/snippets/static14.py index f54c93d3d..8af326fbd 100644 --- a/docs/snippets/static14.py +++ b/docs/snippets/static14.py @@ -1,11 +1,10 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String @@ -15,40 +14,26 @@ from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol: def __init__(self, connection: IPConnection, suffix: str = ""): - super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") - await attr.update(attr.dtype(value)) - - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] - logger.info("Sending attribute value", command=command, attribute=attr) + logger.info("Sending attribute value", command=command) await self._connection.send_command(f"{command}\r\n") + async def send_query(self, param: str, dtype: type[ValueT]) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + return dtype(response.strip("\r\n")) # type: ignore[call-arg] + class OnOffEnum(enum.StrEnum): Off = "0" @@ -56,30 +41,70 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, + ) + self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(Float()) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int) + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -87,6 +112,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -102,7 +139,7 @@ async def update_voltages(self): async def disable_all(self) -> None: self.log_event("Disabling all ramps") for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py index aa2d53a92..35a244115 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -1,56 +1,46 @@ import asyncio import enum import json -from dataclasses import KW_ONLY, dataclass from pathlib import Path from typing import TypeVar -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import LogLevel, configure_logging, logger from fastcs.methods import command, scan +from fastcs.tracer import Tracer from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport -NumberT = TypeVar("NumberT", int, float) +ValueT = TypeVar("ValueT") -@dataclass -class TemperatureControllerAttributeIORef(AttributeIORef): - name: str - _: KW_ONLY - update_period: float | None = 0.2 - - -class TemperatureControllerAttributeIO( - AttributeIO[NumberT, TemperatureControllerAttributeIORef] -): +class TemperatureProtocol(Tracer): def __init__(self, connection: IPConnection, suffix: str = ""): super().__init__() - self._connection = connection self._suffix = suffix - async def update(self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef]): - query = f"{attr.io_ref.name}{self._suffix}?" - response = await self._connection.send_query(f"{query}\r\n") - value = response.strip("\r\n") + async def send_command(self, param: str, value: ValueT, dtype: type[ValueT]): + command = f"{param}{self._suffix}={dtype(value)}" # type: ignore[call-arg] - self.log_event("Query for attribute", query=query, response=value, topic=attr) + logger.info("Sending attribute value", command=command) - await attr.update(attr.dtype(value)) + await self._connection.send_command(f"{command}\r\n") - async def send( - self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT - ) -> None: - command = f"{attr.io_ref.name}{self._suffix}={attr.dtype(value)}" + async def send_query( + self, param: str, dtype: type[ValueT], topic: Tracer | None = None + ) -> ValueT: + query = f"{param}{self._suffix}?" + response = await self._connection.send_query(f"{query}\r\n") + value = dtype(response.strip("\r\n")) # type: ignore[call-arg] - logger.info("Sending attribute value", command=command, attribute=attr) + self.log_event("Query for attribute", topic=topic, query=query, response=value) - await self._connection.send_command(f"{command}\r\n") + return value class OnOffEnum(enum.StrEnum): @@ -59,30 +49,70 @@ class OnOffEnum(enum.StrEnum): class TemperatureRampController(Controller): - start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) - end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) - enabled = AttrRW(Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef("N")) - target = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("T")) - actual = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("A")) - voltage = AttrR(Float()) - def __init__(self, index: int, connection: IPConnection) -> None: suffix = f"{index:02d}" - super().__init__( - f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(connection, suffix)] + self._protocol = TemperatureProtocol(connection, suffix) + super().__init__(f"Ramp{suffix}") + + self.start = AttrRW( + Int(), + getter=Polled(self._get_start, period=0.2), + setter=self._set_start, + ) + self.end = AttrRW( + Int(), + getter=Polled(self._get_end, period=0.2), + setter=self._set_end, + ) + self.enabled = AttrRW( + Enum(OnOffEnum), + getter=Polled(self._get_enabled, period=0.2), + setter=self._set_enabled, ) + self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(Float()) + async def _get_start(self) -> int: + return await self._protocol.send_query("S", int, topic=self.start) -class TemperatureController(Controller): - device_id = AttrR(String(), io_ref=TemperatureControllerAttributeIORef("ID")) - power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef("P")) - ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef("R")) + async def _set_start(self, value: int) -> None: + await self._protocol.send_command("S", value, int) + + async def _get_end(self) -> int: + return await self._protocol.send_query("E", int, topic=self.end) + + async def _set_end(self, value: int) -> None: + await self._protocol.send_command("E", value, int) + async def _get_enabled(self) -> OnOffEnum: + return OnOffEnum(await self._protocol.send_query("N", str, topic=self.enabled)) + + async def _set_enabled(self, value: OnOffEnum) -> None: + await self._protocol.send_command("N", value.value, str) + + async def _get_target(self) -> float: + return await self._protocol.send_query("T", float, topic=self.target) + + async def _get_actual(self) -> float: + return await self._protocol.send_query("A", float, topic=self.actual) + + +class TemperatureController(Controller): def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ip_settings = settings self._connection = IPConnection() + self._protocol = TemperatureProtocol(self._connection) - super().__init__(ios=[TemperatureControllerAttributeIO(self._connection)]) + super().__init__() + + self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.ramp_rate = AttrRW( + Float(), + getter=Polled(self._get_ramp_rate, period=0.2), + setter=self._set_ramp_rate, + ) self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, ramp_count + 1): @@ -90,6 +120,18 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): self._ramp_controllers.append(controller) self.add_sub_controller(f"R{index}", controller) + async def _get_device_id(self) -> str: + return await self._protocol.send_query("ID", str, topic=self.device_id) + + async def _get_power(self) -> float: + return await self._protocol.send_query("P", float, topic=self.power) + + async def _get_ramp_rate(self) -> float: + return await self._protocol.send_query("R", float, topic=self.ramp_rate) + + async def _set_ramp_rate(self, value: float) -> None: + await self._protocol.send_command("R", value, float) + async def connect(self): await self._connection.connect(self._ip_settings) @@ -105,7 +147,7 @@ async def update_voltages(self): async def disable_all(self) -> None: self.log_event("Disabling all ramps") for rc in self._ramp_controllers: - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) diff --git a/docs/tutorials/dynamic-drivers.md b/docs/tutorials/dynamic-drivers.md index 8b02b1e7b..ae55dc608 100644 --- a/docs/tutorials/dynamic-drivers.md +++ b/docs/tutorials/dynamic-drivers.md @@ -43,27 +43,27 @@ implement an `initialise` method to create these dynamically instead. Create a pydantic model to validate the response from the device :::{literalinclude} /snippets/dynamic.py -:lines: 5,18-35 +:lines: 4,30-47 ::: Create a function to parse the dictionary, validate the entries against the model and -create `Attributes`. +create `Attributes`. Each attribute gets a `getter` (and, if writable, a `setter`) built +as a small closure over its `command` and the shared `TemperatureProtocol` instance, +rather than an IO reference - dynamically-created attributes need their IO wired up at +construction time just like statically-declared ones do. :::{literalinclude} /snippets/dynamic.py -:lines: 38-56 +:lines: 50-79 ::: Update the controllers to not define attributes statically and implement initialise -methods to create these attributes dynamically. +methods to create these attributes dynamically, passing the shared `TemperatureProtocol` +down to `create_attributes` so the dynamically-created getters/setters can use it. :::{literalinclude} /snippets/dynamic.py -:lines: 91-131 +:lines: 82-128 ::: -The `suffix` field should also be removed from `TemperatureController` and -`TemperatureRampController` and then not used in `TemperatureControllerAttributeIO` -because the `command` field on `TemperatureControllerParameter` includes this. - TODO: Add `enabled` back in to `TemperatureRampController` and recreate `disable_all` to demonstrate validation of introspected Attributes. diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md index f28f9b603..aa447c13f 100644 --- a/docs/tutorials/static-drivers.md +++ b/docs/tutorials/static-drivers.md @@ -82,7 +82,7 @@ In [1]: controller.device_id Out[1]: AttrR(String()) -In [2]: controller.device_id.get() +In [2]: controller.device_id.readback Out[2]: '' ::: @@ -139,8 +139,10 @@ The `demo.bob` will have been created in the directory the application was run f ## FastCS Device Connection The `Attributes` of a FastCS `Controller` need some IO with the device in order to get -and set values. This is implemented with `AttributeIO`s and connections. Generally each -driver implements its own IO and connection logic, but there are some built in options. +and set values. This is implemented with plain `getter`/`setter` callables passed to the +`Attribute` constructor, together with a connection. Generally each driver implements +its own getter/setter logic and connection, but there are some built in connection +options. Update the controller to create an `IPConnection` to communicate with the simulator over TCP and implement a `connect` method that establishes the connection. The `connect` @@ -165,27 +167,29 @@ The application will now fail to connect if the demo simulation is not running. ::: The `Controller` has now established a connection with the simulator. This connection -can be passed to an `AttributeIO` to enable it to query the device API and update the -value in the `device_id` attribute. Create a `TemperatureControllerAttributeIO` child -class and implement the `update` method to query the device and set the value of the -attribute, and create a `TemperatureControllerAttributeIORef` and pass an instance of -it to the `device_id` attribute to tell the controller what io to use to update it. +can be used by a `getter` callable to query the device API and update the value in the +`device_id` attribute. Note that the `Attribute` now has to be created in `__init__`, +after the connection exists, rather than as a class body instance - a getter needs to +close over a live connection, which doesn't exist yet when the class body is evaluated. +Write a `_get_device_id` method that queries the device and returns its value, and pass +it to `device_id` as `getter`. :::{note} -The `update_period` property tells the base class how often to call `update` +Passing the getter bare, as here, means it is called once at start up. Wrap it in +`Polled(getter, period=...)` to have the base class call it repeatedly instead. ::: ::::{admonition} Code 7 :class: dropdown, hint :::{literalinclude} /snippets/static07.py -:emphasize-lines: 1,3,5-6,15-33,37,43 +:emphasize-lines: 13-19,21-23 ::: :::: :::{note} -In the `update` method, errors won't crash the application, but it prints them to the +If a getter raises, it won't crash the application, but it prints the error to the terminal. - `Update loop ... stopped:` ::: @@ -201,26 +205,28 @@ DEMO:DeviceId SIMTCONT123 The simulator supports many other commands, for example it reports the total power currently being drawn with the `P` command. This can be exposed by adding another -`AttrR` with a `Float` datatype, but the IO only supports the `ID` command to get the -device ID. This new attribute could have its own IO, but it is similar enough that the -existing IO can be support both. +`AttrR` with a `Float` datatype, but so far the getter for `device_id` only knows how to +send the `ID` command. This new attribute could get its own bespoke getter, but the +query-building logic is similar enough between commands that it is worth factoring out. -Modify the IO ref to take a `name` string and update the IO to use it in the query -string sent to the device. Create a new attribute to read the power usage using this. +Extract a small `TemperatureProtocol` class that knows how to send a query or a command +for a given parameter name, casting the response to the right python type. Each +attribute then gets a thin getter method that just names the parameter and delegates to +the protocol. :::{note} All responses from the `IPConnection` are strings. This is fine for the `ID` command -because the value is actually a string, but for `P` the value is a float, so the -`update` methods needs to explicitly cast to the correct type. It can use -`Attribute.dtype` to call the builtin for its datatype - e.g. `int`, `float`, `str`, -etc. +because the value is actually a string, but for `P` the value is a float, so +`TemperatureProtocol.send_query` needs to explicitly cast to the correct type. It takes +the target python type as an argument (e.g. `int`, `float`, `str`) and calls it as a +constructor to perform the cast. ::: :::{admonition} Code 8 :class: dropdown, hint :::{literalinclude} /snippets/static08.py -:emphasize-lines: 10,19-21,33-38,42-43 +:emphasize-lines: 12,15-27,34,38-39,41-45 ::: :::: @@ -229,14 +235,14 @@ Now the IOC has two PVs being polled periodically. The new PV will be visible in Phoebus UI on refresh (right-click). `DEMO:Power` will read as `0` because the simulator is not currently running a ramp. To do that the controller needs to be able to set values on the device, as well as read them back. The ramp rate of the temperature can be -read with the `R` command and set with the `R=...` command. This means the IO also needs -a `send` method to send values to the device. +read with the `R` command and set with the `R=...` command. This means the protocol also +needs a way to send values to the device, which `send_command` already provides. -Update the IO to implement `send` and then add a new `AttrRW` with type `Float` to get -and set the ramp rate. +Add a new `AttrRW` with type `Float` to get and set the ramp rate, giving it both a +`getter` and a `setter`. :::{note} -The set commands do not return a response, so use the `send_command` method instead of +The set commands do not return a response, so the setter uses `send_command` instead of `send_query`. ::: @@ -244,7 +250,7 @@ The set commands do not return a response, so use the `send_command` method inst :class: dropdown, hint :::{literalinclude} /snippets/static09.py -:emphasize-lines: 7,40-44,48-50 +:emphasize-lines: 4,40-45,53-57 ::: :::: @@ -279,16 +285,17 @@ has. This can be done with the use of sub controllers. Controllers can be arbitr nested to match the structure of a device and this structure is then mirrored to the transport layer for the visibility of the user. -Create a `TemperatureRampController` with two `AttrRW`s the ramp start and end, update -the IO to include an optional suffix for the commands so that it can be shared with -the parent `TemperatureController` and add an argument to define how many ramps there -are, which is used to register the correct number of ramp controllers with the parent. +Create a `TemperatureRampController` with two `AttrRW`s for the ramp start and end, give +`TemperatureProtocol` an optional suffix so an instance can be shared with the parent +`TemperatureController` while still addressing an individual ramp, and add an argument +to define how many ramps there are, which is used to register the correct number of ramp +controllers with the parent. ::::{admonition} Code 10 :class: dropdown, hint :::{literalinclude} /snippets/static10.py -:emphasize-lines: 10,28,32,35,44,48-56,64,70-74,83 +:emphasize-lines: 30-53,57,73-77 ::: :::: @@ -313,7 +320,7 @@ Add an `AttrRW` to the `TemperatureRampController`s with an `Enum` type, using a :class: dropdown, hint :::{literalinclude} /snippets/static11.py -:emphasize-lines: 1,11,49-51,57 +:emphasize-lines: 1,31-33,48-53,67-71 ::: :::: @@ -355,39 +362,41 @@ The applied voltage for each ramp is also available with the `V?` command, but t is an array with each element corresponding to a ramp. Here it will be simplest to manually fetch the array in the parent controller and pass each value into ramp controller. This can be done with a `scan` method - these are called at a defined rate, -similar to the `update` method of an `AttributeIO`. +similar to how each attribute's getter is polled. -Add an `AttrR` for the voltage to the `TemperatureRampController`, but do not pass it an -IO ref. Then add a method to the `TemperatureController` with a `@scan` decorator that -gets the array of voltages and sets each ramp controller with its value. Also add -`AttrR`s for the target and actual temperature for each ramp as described above. +Add an `AttrR` for the voltage to the `TemperatureRampController`, but do not give it a +`getter` - it is a soft attribute, pushed to directly by the parent controller's scan +method instead. Then add a method to the `TemperatureController` with a `@scan` +decorator that gets the array of voltages and sets each ramp controller with its value. +Also add `AttrR`s for the target and actual temperature for each ramp as described +above. ::::{admonition} Code 12 :class: dropdown, hint :::{literalinclude} /snippets/static12.py -:emphasize-lines: 2,16,60-62,91-97 +:emphasize-lines: 11,56-58,78-82,123-129 ::: :::: Creating attributes is intended to be a simple API covering most use cases, but where more flexibility is needed wrapped controller methods can be useful to avoid adding -complexity to the IO to handle a small subset of attributes. It is also useful for -implementing higher level logic on top of the attributes that expose the API of a device -directly. For example, it would be useful to have a single button to stop all of the -ramps at the same time. This can be done with a `command` method. These are similar to -`scan` methods except that they create an API in transport layer in the same way an +complexity to a getter/setter to handle a small subset of attributes. It is also useful +for implementing higher level logic on top of the attributes that expose the API of a +device directly. For example, it would be useful to have a single button to stop all of +the ramps at the same time. This can be done with a `command` method. These are similar +to `scan` methods except that they create an API in transport layer in the same way an attribute does. Add a method with a `@command` decorator to set enabled to false in every ramp -controller. +controller by calling `set` on each `enabled` attribute. ::::{admonition} Code 13 :class: dropdown, hint :::{literalinclude} /snippets/static13.py -:emphasize-lines: 1,17,100-105 +:emphasize-lines: 1,132-137 ::: :::: @@ -412,14 +421,14 @@ application. To enable logging from the core framework call `configure_logging` arguments (the default logging level is INFO). To log messages from a driver, import the singleton `logger` directly. -Create a module-level logger to log status of the application start up. Create a class -logger for `TemperatureControllerAttributeIO` to log the commands it sends. +Create a module-level logger to log status of the application start up, and use it +inside `TemperatureProtocol.send_command` to log the commands it sends. ::::{admonition} Code 14 :class: dropdown, hint :::{literalinclude} /snippets/static14.py -:emphasize-lines: 13,48,110,115 +:emphasize-lines: 12,28,145,150 ::: :::: @@ -427,55 +436,48 @@ logger for `TemperatureControllerAttributeIO` to log the commands it sends. Try setting a PV and check the console for the log message it prints. ``` -[2025-11-18 11:26:41.065+0000 I] Sending attribute value [TemperatureControllerAttributeIO] command=E01=70, attribute=AttrRW(path=R1.end, datatype=Int, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='E')) +[2026-01-01 11:26:41.065+0000 I] Sending attribute value [fastcs] command=E01=70 ``` -A similar log message could be added for the update method of the IO, but this would be -very verbose. For this use case FastCS provides the `Tracer` class, which is inherited -by `AttributeIO`, among other core FastCS classes. This enables the logging of `TRACE` -level log messages that are disabled by default, but can be enabled at runtime. +A similar log message could be added for the getters, but this would be very verbose. +For this use case FastCS provides the `Tracer` class, which can be inherited by anything +that wants to support selective, per-instance logging - `Attribute` and `BaseController` +already do. This enables the logging of `TRACE` level log messages that are disabled by +default, but can be enabled at runtime. -Update the `send` method of the IO to log a message showing the query that was sent and -the response from the device. Update the `configure_logging` call to pass -`LogLevel.TRACE` as the log level, so that when tracing is enabled the messages are -visible. +Make `TemperatureProtocol` inherit `Tracer` too, and update `send_query` to take a +`topic` argument and log a message showing the query that was sent and the response +from the device via `self.log_event`, passing through the attribute doing the query as +the `topic`. Update each getter to pass its own attribute as `topic`. Update the +`configure_logging` call to pass `LogLevel.TRACE` as the log level, so that when tracing +is enabled the messages are visible. ::::{admonition} Code 15 :class: dropdown, hint :::{literalinclude} /snippets/static15.py -:emphasize-lines: 13,49-51,118 +:emphasize-lines: 12,14,21,34-36,41,125,153 ::: :::: Enable tracing on the `power` attribute by calling `enable_tracing` and then enable a -ramp so that the value updates. Check the console to see the messages. Call +ramp so that the value updates. Check the console to see the messages. Call `disable_tracing` to disable the log messages for `power`. ``` In [1]: controller.power.enable_tracing() -[2025-11-18 11:11:12.060+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=0.0 -[2025-11-18 11:11:12.060+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=0.0 -[2025-11-18 11:11:12.060+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=0.0 -[2025-11-18 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transports.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 -[2025-11-18 11:11:12.195+0000 I] Sending attribute value [TemperatureControllerAttributeIO] command=N01=1, attribute=AttrRW(path=R1.enabled, datatype=Enum, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='N')) -[2025-11-18 11:11:12.261+0000 T] Update attribute [AttrR] -[2025-11-18 11:11:12.262+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=29.040181873093132 -[2025-11-18 11:11:12.262+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=29.040181873093132 -[2025-11-18 11:11:12.262+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=29.04 -[2025-11-18 11:11:12.463+0000 T] Update attribute [AttrR] -[2025-11-18 11:11:12.464+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=30.452524641833854 -[2025-11-18 11:11:12.464+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=30.452524641833854 -[2025-11-18 11:11:12.465+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=30.45 +[2026-01-01 11:11:12.060+0000 T] Query for attribute [fastcs] query=P?, response=0.0 +[2026-01-01 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transports.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 +[2026-01-01 11:11:12.195+0000 I] Sending attribute value [fastcs] command=N01=1 +[2026-01-01 11:11:12.262+0000 T] Query for attribute [fastcs] query=P?, response=29.040181873093132 +[2026-01-01 11:11:12.463+0000 T] Query for attribute [fastcs] query=P?, response=30.452524641833854 In [2]: controller.power.disable_tracing() ``` -These log messages include other trace loggers that log messages with `power` as the -`topic`, so they also appear automatically, so the log messages show changes to the -attribute throughout the stack: the query to the device and its response, the value the -attribute is set to, and the value that the PV in the EPICS CA transport is set to. - +Only messages with `power` as their topic appear, even though every attribute's getter +is querying the device on the same period - other attributes' queries stay silent until +tracing is enabled on them too. :::{note} The `Tracer` can also be used as a module-level instance for use in free functions. diff --git a/pyproject.toml b/pyproject.toml index f178a375c..95129fb4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,21 @@ addopts = """ """ # https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings # https://github.com/DiamondLightSource/FastCS/issues/230 -filterwarnings = "error" +# +# The two pytest-generated warnings are downgraded to "report but do not fail". +# They are raised for events that happen *outside* any test - an exception during +# garbage collection, or in a non-main thread - and pytest attributes them to +# whichever test happens to be running at the time. This suite leaves objects +# alive in its subprocess and multiprocessing fixtures (run_ioc_as_subprocess's +# forkserver and Queues, the tickit Popen in test_docs_snippets), so the +# resulting ResourceWarning lands on an unrelated test, and which one varies by +# Python version and by run. They are still printed, so real leaks stay visible; +# track them down with PYTHONTRACEMALLOC=25, which adds the allocation traceback. +filterwarnings = [ + "error", + "default::pytest.PytestUnraisableExceptionWarning", + "default::pytest.PytestUnhandledThreadExceptionWarning", +] # Doctest python code in docs, python code in src docstrings, test functions in tests testpaths = "docs src tests" timeout = 5 diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index d0f5e59f0..e968192b2 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -1,10 +1,12 @@ from .attr_r import AttrR as AttrR +from .attr_r import Getter as Getter +from .attr_r import NotPolled as NotPolled +from .attr_r import Polled as Polled +from .attr_r import Schedule as Schedule from .attr_rw import AttrRW as AttrRW from .attr_w import AttrW as AttrW +from .attr_w import Setter as Setter from .attribute import Attribute as Attribute from .attribute import AttributeAccessMode as AttributeAccessMode -from .attribute_io import AnyAttributeIO as AnyAttributeIO -from .attribute_io import AttributeIO as AttributeIO -from .attribute_io_ref import AttributeIORef as AttributeIORef -from .attribute_io_ref import AttributeIORefT as AttributeIORefT from .hinted_attribute import HintedAttribute as HintedAttribute +from .update import Update as Update diff --git a/src/fastcs/attributes/_infer_datatype.py b/src/fastcs/attributes/_infer_datatype.py new file mode 100644 index 000000000..a601781d6 --- /dev/null +++ b/src/fastcs/attributes/_infer_datatype.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import enum +import inspect +from collections.abc import Callable +from typing import Any, get_args, get_origin + +from fastcs.attributes.update import Update +from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String + +_DEFAULT_DATATYPES: dict[type, Callable[[], DataType]] = { + int: Int, + float: Float, + bool: Bool, + str: String, +} + + +def _unwrap_update_annotation(annotation: Any) -> Any: + if get_origin(annotation) is Update: + args = get_args(annotation) + return args[0] if args else annotation + return annotation + + +def _datatype_for_type(py_type: Any) -> DataType | None: + if py_type in _DEFAULT_DATATYPES: + return _DEFAULT_DATATYPES[py_type]() + if isinstance(py_type, type) and issubclass(py_type, enum.Enum): + return Enum(py_type) + return None + + +def infer_datatype_from_getter(getter: Callable) -> DataType | None: + """Infer a default ``DataType`` from a getter's return type annotation.""" + signature = inspect.signature(getter, eval_str=True) + annotation = signature.return_annotation + if annotation is inspect.Signature.empty: + return None + return _datatype_for_type(_unwrap_update_annotation(annotation)) + + +def infer_datatype_from_setter(setter: Callable) -> DataType | None: + """Infer a default ``DataType`` from a setter's value parameter annotation.""" + signature = inspect.signature(setter, eval_str=True) + parameters = list(signature.parameters.values()) + if not parameters: + return None + annotation = parameters[0].annotation + if annotation is inspect.Signature.empty: + return None + return _datatype_for_type(annotation) diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index 28d66c07b..3e4243d5a 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -1,75 +1,149 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import KW_ONLY, dataclass, replace +from typing import Any, Generic +from fastcs.attributes._infer_datatype import infer_datatype_from_getter from fastcs.attributes.attribute import Attribute, AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.attributes.update import Update from fastcs.attributes.util import AttrValuePredicate, PredicateEvent from fastcs.datatypes import DataType, DType_T from fastcs.logging import logger +from fastcs.util import ONCE -AttrIOUpdateCallback = Callable[["AttrR[DType_T, Any]"], Coroutine[None, None, None]] -"""An AttributeIO callback that takes an AttrR and updates its value""" -AttrUpdateCallback = Callable[[], Coroutine[None, None, None]] -"""A callback to be called periodically to update an attribute""" -AttrOnUpdateCallback = Callable[[DType_T], Coroutine[None, None, None]] -"""A callback to be called when the value of the attribute is updated""" +Getter = Callable[[], Awaitable[DType_T | Update[DType_T]]] +"""A callable that fetches a fresh value for an attribute from its source""" +AttrReadbackCallback = Callable[[DType_T], Coroutine[None, None, None]] +"""A callback to be called when the readback of the attribute updates""" -class AttrR(Attribute[DType_T, AttributeIORefT]): +@dataclass +class Polled(Generic[DType_T]): + """A getter to be read repeatedly, every ``period`` seconds:: + + AttrR(getter=Polled(protocol.get_temperature, period=0.1)) + + Use this for values the device changes on its own, such as readings and status. + A getter passed without a schedule is read once, when the controller connects. + """ + + getter: Getter[DType_T] | None = None + _: KW_ONLY + period: float + + def __call__(self, getter: Getter[DType_T]) -> Polled[DType_T]: + """Bind a getter, so a schedule can also be applied as a decorator.""" + return replace(self, getter=getter) + + +@dataclass +class NotPolled(Generic[DType_T]): + """A getter that is never read on a schedule:: + + AttrR(getter=NotPolled(protocol.get_label)) + + The value is only set explicitly - from a ``@scan`` or a subscription calling + ``attr.update()`` - or read on demand with ``await attr.poll()``. This is not the + same as an attribute with no getter at all, which has nothing to read. + """ + + getter: Getter[DType_T] | None = None + + def __call__(self, getter: Getter[DType_T]) -> NotPolled[DType_T]: + """Bind a getter, so a schedule can also be applied as a decorator.""" + return replace(self, getter=getter) + + +Schedule = Polled[DType_T] | NotPolled[DType_T] +"""A getter with a reading schedule attached""" + + +class AttrR(Attribute[DType_T]): """A read-only ``Attribute``""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, + datatype: DataType[DType_T] | None = None, + getter: Getter[DType_T] | Schedule[DType_T] | None = None, initial_value: DType_T | None = None, - description: str | None = None, + **kwargs: Any, ) -> None: - super().__init__(datatype, io_ref, group, description=description) + match getter: + case Polled() | NotPolled(): + if getter.getter is None: + raise ValueError( + f"{type(getter).__name__} was given no getter to schedule" + ) + resolved_getter = getter.getter + poll_period = getter.period if isinstance(getter, Polled) else None + case None: + resolved_getter, poll_period = None, None + case _: + # A getter with no schedule is read once, when the controller + # connects - the safe default, and what a bare ``@attr`` means. + resolved_getter, poll_period = getter, ONCE + + if datatype is None and resolved_getter is not None: + datatype = infer_datatype_from_getter(resolved_getter) + + # Pass the datatype on rather than validating it here: in an ``AttrRW`` the + # setter may still supply it, and ``Attribute`` makes the final check. + super().__init__(datatype, **kwargs) + self._value: DType_T = ( - datatype.initial_value if initial_value is None else initial_value + self._datatype.initial_value if initial_value is None else initial_value ) - self._update_callback: AttrIOUpdateCallback[DType_T] | None = None - """Callback to update the value of the attribute with an IO to the source""" - self._on_update_callbacks: ( - list[tuple[AttrOnUpdateCallback[DType_T], bool]] | None + self._getter = resolved_getter + self._poll_period: float | None = poll_period + """Period in seconds between calls to poll(), or ONCE, or None (on-demand)""" + self._readback_callbacks: ( + list[tuple[AttrReadbackCallback[DType_T], bool]] | None ) = None - """Callbacks to publish changes to the value of the attribute""" + """Callbacks to publish changes to the readback of the attribute""" self._on_update_events: set[PredicateEvent[DType_T]] = set() """Events to set when the value satisifies some predicate""" - def get(self) -> DType_T: - """Get the cached value of the attribute.""" + @property + def readback(self) -> DType_T: + """The last known value of the attribute.""" return self._value + def has_getter(self) -> bool: + return self._getter is not None + + @property + def poll_period(self) -> float | None: + return self._poll_period + @property def access_mode(self) -> AttributeAccessMode: return "r" - async def update(self, value: Any) -> None: - """Update the value of the attibute + async def update(self, value: DType_T | Update[DType_T]) -> None: + """Update the value of the attribute This sets the cached value of the attribute presented in the API. It should - generally only be called from an IO or a controller that is updating the value - from some underlying source. + generally only be called from a getter or a controller that is updating the + value from some underlying source. Any update callbacks will be called with the new value and any update events with predicates satisfied by the new value will be set. - To request a change to the setpoint of the attribute, use the ``put`` method, + To request a change to the setpoint of the attribute, use the ``set`` method, which will attempt to apply the change to the underlying source. Args: - value: The new value of the attribute + value: The new value of the attribute, or an ``Update`` wrapping it Raises: ValueError: If the value fails to be validated to DType_T """ + if isinstance(value, Update): + value = value.readback + self.log_event("Attribute set", value=repr(value), attribute=self) _previous_value = self._value @@ -85,57 +159,53 @@ async def update(self, value: Any) -> None: e for e in self._on_update_events if e.set(self._value) } - if self._on_update_callbacks is not None: - callbacks_to_call: list[AttrOnUpdateCallback[DType_T]] = [ + if self._readback_callbacks is not None: + callbacks_to_call: list[AttrReadbackCallback[DType_T]] = [ cb - for cb, always in self._on_update_callbacks + for cb, always in self._readback_callbacks if always or not self.datatype.equal(self._value, _previous_value) ] try: await asyncio.gather(*[cb(self._value) for cb in callbacks_to_call]) except Exception as e: logger.opt(exception=e).error( - "On update callbacks failed", + "Readback callbacks failed", attribute=self, value=repr(self._value), ) raise - def add_on_update_callback( - self, callback: AttrOnUpdateCallback[DType_T], always: bool = False - ) -> None: - """Add a callback to be called when the value of the attribute is updated + async def poll(self) -> DType_T: + """Fetch a fresh value from the getter, cache it, and return it.""" + if self._getter is None: + raise RuntimeError(f"{self} has no getter") - The callback will be called with the updated value. + self.log_event("Poll attribute", topic=self) + result = await self._getter() + await self.update(result) + return self._value - """ - if self._on_update_callbacks is None: - self._on_update_callbacks = [] - self._on_update_callbacks.append((callback, always)) + def add_readback_callback( + self, callback: AttrReadbackCallback[DType_T], always: bool = False + ) -> None: + """Add a callback to be called when the readback of the attribute updates - def set_update_callback(self, callback: AttrIOUpdateCallback[DType_T]): - """Set the callback to update the value of the attribute from the source + The callback will be called with the updated readback value. Transports + should use this to publish the attribute's readback, and + ``AttrW.add_setpoint_callback`` to publish its setpoint. - The callback will be converted to an async task and called periodically. + Args: + callback: The callback to call with the updated readback value + always: Whether to call the callback on every ``update``, rather than + only when the new value differs from the cached one. Defaults to + ``False``, so an update that does not change the value is not + published. Pass ``True`` for a callback that must see every update + - one that timestamps it, or counts it, rather than displaying it. """ - if self._update_callback is not None: - raise RuntimeError("Attribute already has an IO update callback") - - self._update_callback = callback - - def bind_update_callback(self) -> AttrUpdateCallback: - """Bind self into the registered IO update callback""" - if self._update_callback is None: - raise RuntimeError("Attribute has no update callback") - else: - update_callback = self._update_callback - - async def update_attribute(): - self.log_event("Update attribute", topic=self) - await update_callback(self) - - return update_attribute + if self._readback_callbacks is None: + self._readback_callbacks = [] + self._readback_callbacks.append((callback, always)) async def wait_for_predicate( self, predicate: AttrValuePredicate[DType_T], *, timeout: float diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py index 5f0c2edbd..4214254e2 100644 --- a/src/fastcs/attributes/attr_rw.py +++ b/src/fastcs/attributes/attr_rw.py @@ -1,42 +1,84 @@ -from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attr_w import AttrW +from __future__ import annotations + +from typing import Any + +from fastcs.attributes.attr_r import AttrR, Getter, Schedule +from fastcs.attributes.attr_w import AttrW, Setter from fastcs.attributes.attribute import AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.attributes.update import Update from fastcs.datatypes import DataType, DType_T +from fastcs.logging import logger -class AttrRW(AttrR[DType_T, AttributeIORefT], AttrW[DType_T, AttributeIORefT]): +class AttrRW(AttrR[DType_T], AttrW[DType_T]): """A read-write ``Attribute``.""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, + datatype: DataType[DType_T] | None = None, + getter: Getter[DType_T] | Schedule[DType_T] | None = None, + setter: Setter[DType_T] | None = None, initial_value: DType_T | None = None, - description: str | None = None, + **kwargs: Any, ): - super().__init__(datatype, io_ref, group, initial_value, description) - - self._setpoint_initialised = False - - if io_ref is None: - self.set_on_put_callback(self._internal_update) + # There is no datatype handling to do here. ``AttrR`` infers it from the + # getter and ``AttrW`` from the setter; the MRO runs both in turn, so + # whichever can resolve it does, and ``Attribute`` makes the final check. + super().__init__( + datatype, + getter=getter, + setter=setter, + initial_value=initial_value, + **kwargs, + ) @property def access_mode(self) -> AttributeAccessMode: return "rw" - async def _internal_update( - self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T - ): - """Update value directly when Attribute has no IO""" - assert attr is self - await self.update(value) + async def update(self, value: DType_T | Update[DType_T]) -> None: + """Update the readback of the attribute, and its setpoint if appropriate. + + An ``Update`` carrying a ``setpoint`` publishes that too - the mechanism for + a device that reports its own setpoint. Otherwise, the first readback to + arrive establishes the setpoint, so that a setpoint display shows the + device's value rather than the datatype's default until first written. - async def update(self, value: DType_T): + """ await super().update(value) - if not self._setpoint_initialised: - await self._call_sync_setpoint_callbacks(self._value) - self._setpoint_initialised = True + if isinstance(value, Update) and value.setpoint is not None: + await self.update_setpoint(value.setpoint) + elif not self._setpoint_known: + await self.update_setpoint(self._value) + + async def set(self, value: DType_T) -> None: + """Request a new value for the attribute. + + With no setter, this is a soft attribute: the requested value is pushed + straight to the readback. With a setter, a returned value is treated as the + device's accepted/clamped value and is applied to the readback as well as + the setpoint. + + """ + await self.update_setpoint(value) + + if self._setter is None: + await self.update(self._setpoint) + else: + try: + result = await self._setter(self._setpoint) + except Exception as e: + logger.opt(exception=e).error( + "Set failed", attribute=self, setpoint=self._setpoint + ) + else: + if isinstance(result, Update): + await self.update(result) + elif result is not None: + # A bare value is the device's accepted/clamped value - both the + # new readback and what it understood us to ask for. + await self.update_setpoint(result) + await self.update(result) + + self.log_event("Set complete", setpoint=self._setpoint, attribute=self) diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py index 3e6a4517d..696d66dc7 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -1,98 +1,129 @@ +from __future__ import annotations + import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from typing import Any +from fastcs.attributes._infer_datatype import infer_datatype_from_setter from fastcs.attributes.attribute import Attribute, AttributeAccessMode -from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.attributes.update import Update from fastcs.datatypes import DataType, DType_T from fastcs.logging import logger -AttrOnPutCallback = Callable[["AttrW[DType_T, Any]", DType_T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" -AttrSyncSetpointCallback = Callable[[DType_T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" +Setter = Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]] +"""A callable that applies a new setpoint to an attribute's source""" +AttrSetpointCallback = Callable[[DType_T], Coroutine[None, None, None]] +"""A callback to be called when the setpoint of the attribute updates""" -class AttrW(Attribute[DType_T, AttributeIORefT]): +class AttrW(Attribute[DType_T]): """A write-only ``Attribute``.""" def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - description: str | None = None, + datatype: DataType[DType_T] | None = None, + setter: Setter[DType_T] | None = None, + **kwargs: Any, ) -> None: - super().__init__( - datatype, # type: ignore - io_ref, - group, - description=description, - ) - self._on_put_callback: AttrOnPutCallback[DType_T] | None = None - """Callback to action a change to the setpoint of the attribute""" - self._sync_setpoint_callbacks: list[AttrSyncSetpointCallback[DType_T]] = [] + if datatype is None and setter is not None: + datatype = infer_datatype_from_setter(setter) + + super().__init__(datatype, **kwargs) + + self._setter = setter + self._setpoint: DType_T = self._datatype.initial_value + self._setpoint_known = False + """Whether the setpoint reflects a real value rather than the datatype default + + Until something establishes it - a write, or (for an ``AttrRW``) the first + readback - the setpoint is just the datatype's default and means nothing. + """ + self._setpoint_callbacks: list[AttrSetpointCallback[DType_T]] = [] """Callbacks to publish changes to the setpoint of the attribute""" + @property + def setpoint(self) -> DType_T: + """The last-requested value of the attribute.""" + return self._setpoint + + def has_setter(self) -> bool: + return self._setter is not None + @property def access_mode(self) -> AttributeAccessMode: return "w" - async def put(self, setpoint: DType_T, sync_setpoint: bool = False) -> None: - """Set the setpoint of the attribute + def add_setpoint_callback(self, callback: AttrSetpointCallback[DType_T]) -> None: + """Add a callback to be called when the setpoint of the attribute updates + + The callback will be called with the updated setpoint. Transports should + use this to publish the attribute's setpoint rather than tracking their own, + so that every transport agrees on it however the change was made. + + """ + self._setpoint_callbacks.append(callback) - This should be called by clients to the attribute such as transports to apply a - change to the attribute. The ``_on_put_callback`` will be called with this new - setpoint, which may or may not take effect depending on the validity of the new - value. For example, if the attribute has an IO to some device, the value might - be rejected. + async def update_setpoint(self, value: DType_T) -> None: + """Cache a new setpoint and publish it to the setpoint callbacks. - To directly change the value of the attribute, for example from an update loop - that has read a new value from some underlying source, call `AttrR.update`. + This does no IO - it is the setpoint-side counterpart of ``AttrR.update``. """ - setpoint = self._datatype.validate(setpoint) - if self._on_put_callback is not None: + self._setpoint = self._datatype.validate(value) + self._setpoint_known = True + + if self._setpoint_callbacks: try: - await self._on_put_callback(self, setpoint) + await asyncio.gather( + *[cb(self._setpoint) for cb in self._setpoint_callbacks] + ) except Exception as e: logger.opt(exception=e).error( - "Put failed", attribute=self, setpoint=setpoint + "Setpoint callbacks failed", + attribute=self, + setpoint=repr(self._setpoint), ) + raise + + def _setter_result_setpoint( + self, result: DType_T | Update[DType_T] + ) -> DType_T | None: + """The setpoint a setter's return value asks for, if any.""" + if isinstance(result, Update): + # A bare readback with no setpoint leaves the cached setpoint alone. + return result.setpoint + # A bare value is the device's accepted/clamped value - both what it will + # report and what it understood us to ask for. + return result + + async def set(self, value: DType_T) -> None: + """Request a new value for the attribute + + This should be called by clients to the attribute such as transports to apply + a change to the attribute. ``value`` is cached as the setpoint, then the + setter (if any) is called to apply it to the underlying source - the value + might be rejected or clamped, depending on the validity of the new value. If + the setter returns a value, that is treated as the source's accepted/clamped + value and becomes the new cached setpoint. + + To directly change the readback of an attribute, for example from an update + loop that has read a new value from some underlying source, call + ``AttrR.update``. - if sync_setpoint: + """ + await self.update_setpoint(value) + + if self._setter is not None: try: - await self._call_sync_setpoint_callbacks(setpoint) + result = await self._setter(self._setpoint) except Exception as e: logger.opt(exception=e).error( - "Sync setpoint failed", attribute=self, setpoint=setpoint + "Set failed", attribute=self, setpoint=self._setpoint ) + else: + if result is not None: + accepted = self._setter_result_setpoint(result) + if accepted is not None: + await self.update_setpoint(accepted) - self.log_event("Put complete", setpoint=setpoint, attribute=self) - - async def _call_sync_setpoint_callbacks(self, setpoint: DType_T) -> None: - if self._sync_setpoint_callbacks: - await asyncio.gather( - *[cb(setpoint) for cb in self._sync_setpoint_callbacks] - ) - - def set_on_put_callback(self, callback: AttrOnPutCallback[DType_T]) -> None: - """Set the callback to call when the setpoint is changed - - The callback will be called with the attribute and the new setpoint. - - """ - if self._on_put_callback is not None: - raise RuntimeError("Attribute already has an on put callback") - - self._on_put_callback = callback - - def add_sync_setpoint_callback( - self, callback: AttrSyncSetpointCallback[DType_T] - ) -> None: - """Add a callback to publish changes to the setpoint of the attribute - - The callback will be called with the new setpoint. - - """ - self._sync_setpoint_callbacks.append(callback) + self.log_event("Set complete", setpoint=self._setpoint, attribute=self) diff --git a/src/fastcs/attributes/attribute.py b/src/fastcs/attributes/attribute.py index ca4955b53..f98e0ebab 100644 --- a/src/fastcs/attributes/attribute.py +++ b/src/fastcs/attributes/attribute.py @@ -2,14 +2,13 @@ from collections.abc import Callable from typing import Generic, Literal -from fastcs.attributes.attribute_io_ref import AttributeIORefT from fastcs.datatypes import DataType, DType, DType_T from fastcs.tracer import Tracer AttributeAccessMode = Literal["r", "w", "rw"] -class Attribute(Generic[DType_T, AttributeIORefT], Tracer, ABC): +class Attribute(Generic[DType_T], Tracer, ABC): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the FastCS class. @@ -17,17 +16,24 @@ class Attribute(Generic[DType_T, AttributeIORefT], Tracer, ABC): def __init__( self, - datatype: DataType[DType_T], - io_ref: AttributeIORefT | None = None, + datatype: DataType[DType_T] | None = None, group: str | None = None, description: str | None = None, ) -> None: super().__init__() + # Subclasses may infer the datatype from a getter's return annotation or a + # setter's value annotation and pass the result down; by the time it reaches + # here it must be resolved. + if datatype is None: + raise ValueError( + "datatype must be given explicitly, or be inferable from the " + "getter's return annotation or the setter's value annotation" + ) + assert issubclass(datatype.dtype, DType), ( f"Attr type must be one of {DType}, received type {datatype.dtype}" ) - self._io_ref = io_ref self._datatype: DataType[DType_T] = datatype self._group = group self.enabled = True @@ -41,15 +47,6 @@ def __init__( self._name = "" self._path = [] - @property - def io_ref(self) -> AttributeIORefT: - if self._io_ref is None: - raise RuntimeError(f"{self} has no AttributeIORef") - return self._io_ref - - def has_io_ref(self): - return self._io_ref is not None - @property def datatype(self) -> DataType[DType_T]: return self._datatype @@ -115,4 +112,4 @@ def __repr__(self): full_name = self.full_name or None datatype = self._datatype.__class__.__name__ - return f"{name}(name={full_name}, datatype={datatype}, io_ref={self._io_ref})" + return f"{name}(name={full_name}, datatype={datatype})" diff --git a/src/fastcs/attributes/attribute_io.py b/src/fastcs/attributes/attribute_io.py deleted file mode 100644 index bc2749770..000000000 --- a/src/fastcs/attributes/attribute_io.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any, Generic, cast, get_args - -from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attr_w import AttrW -from fastcs.attributes.attribute_io_ref import AttributeIORef, AttributeIORefT -from fastcs.datatypes import DType_T -from fastcs.tracer import Tracer - - -class AttributeIO(Generic[DType_T, AttributeIORefT], Tracer): - """Base class for performing IO for an `Attribute` - - This class should be inherited to implement reading and writing values from - ``Attributes`` via some API. For read, ``Attribute``s implement the ``update`` - method and for write, ``Attribute`` implement the ``send`` method. - - Concrete implementations of this class must be parameterised with a specific - ``AttributeIORef`` that defines exactly what part of the API the ``Attribute`` - corresponds to. See the docstring for `AttributeIORef` for more information. - """ - - ref_type = AttributeIORef - - def __init_subclass__(cls) -> None: - # sets ref_type from subclass generic args - # from python 3.12 we can use types.get_original_bases - args = get_args(cast(Any, cls).__orig_bases__[0]) - cls.ref_type = args[1] - - def __init__(self): - super().__init__() - - async def update(self, attr: AttrR[DType_T, AttributeIORefT]) -> None: - """Update `AttrR` value from device - - This method will be called in `AttrR.update` in a background task. - - Exceptions raised by this method will be caught and logged with a full stack - trace. If using targeted try-except blocks to log more specific errors, this - should be done with stack trace and exceptions should be re-raised to be handled - by FastCS. - - """ - raise NotImplementedError() - - async def send(self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T) -> None: - """Send `Attribute` value to device - - This method will be called in `AttrW.put`, generally from a `Transport`. - - Exceptions raised by this method will be caught and logged with a full stack - trace. If using targetted try-except blocks to log more specific errors, this - should be done with stack trace and exceptions should be re-raised to be handled - by FastCS.. - - """ - raise NotImplementedError() - - -AnyAttributeIO = AttributeIO[Any] diff --git a/src/fastcs/attributes/attribute_io_ref.py b/src/fastcs/attributes/attribute_io_ref.py deleted file mode 100644 index 575025822..000000000 --- a/src/fastcs/attributes/attribute_io_ref.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import KW_ONLY, dataclass - -from typing_extensions import TypeVar - - -@dataclass -class AttributeIORef: - """Base for references to define IO for an ``Attribute`` over an API. - - This object acts as a specification of the API that its corresponding - ``AttributeIO`` should access for a given ``Attribute``. The fields necessary to - distinguish between different ``Attributes`` is an implementation detail of the IO, - but some examples are a string to send over a TCP port, or URI within an HTTP - server. - """ - - # Make fields keyword-only so that child classes can have fields without defaults - _: KW_ONLY - update_period: float | None = None - """Period in seconds between attribute updates, or `ONCE`""" - - -AttributeIORefT = TypeVar( - "AttributeIORefT", bound=AttributeIORef, default=AttributeIORef, covariant=True -) -"""An `AttributeIORef` for an `Attribute`""" diff --git a/src/fastcs/attributes/update.py b/src/fastcs/attributes/update.py new file mode 100644 index 000000000..be84f4be2 --- /dev/null +++ b/src/fastcs/attributes/update.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic + +from fastcs.datatypes import DType_T + + +@dataclass +class Update(Generic[DType_T]): + """A value returned from a getter or setter, with optional metadata. + + A getter or setter may return a bare value, or wrap it in ``Update`` to say + more about it: + + - ``timestamp`` - when the value was obtained. ``None`` means the framework + should stamp it with the time the update was received. + - ``setpoint`` - a setpoint to publish alongside the readback. ``None`` leaves + the cached setpoint untouched. + + A bare value returned from a setter is equivalent to + ``Update(readback=value, setpoint=value)`` - the device's accepted or clamped + value, which is both what it will report and what was asked of it. + """ + + readback: DType_T + timestamp: float | None = None + setpoint: DType_T | None = None diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index 8b29ed6d2..725b02e53 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -1,7 +1,5 @@ from __future__ import annotations -from collections import Counter -from collections.abc import Sequence from copy import deepcopy from typing import ( TypeVar, @@ -11,7 +9,7 @@ get_type_hints, ) -from fastcs.attributes import AnyAttributeIO, Attribute, AttrR, AttrW, HintedAttribute +from fastcs.attributes import Attribute, HintedAttribute from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger from fastcs.methods import Command, Method, Scan, UnboundCommand, UnboundScan @@ -41,7 +39,6 @@ def __init__( self, path: list[str] | None = None, description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: super().__init__() @@ -64,10 +61,6 @@ def __init__( self._bind_attrs() - ios = ios or [] - self._attribute_ref_io_map = {io.ref_type: io for io in ios} - self._validate_io(ios) - def _find_type_hints(self): """Find `Attribute` and `Controller` type hints for introspection validation""" for name, hint in get_type_hints(type(self)).items(): @@ -81,7 +74,7 @@ def _find_type_hints(self): if args is None: dtype = None else: - if len(args) == 2: + if len(args) == 1: dtype = args[0] else: raise TypeError( @@ -156,16 +149,6 @@ class method and a controller instance, so that it can be called from any ): self.add_scan(attr_name, unbound_scan.bind(self)) - def _validate_io(self, ios: Sequence[AnyAttributeIO]): - """Validate that there is exactly one AttributeIO class registered to the - controller for each type of AttributeIORef belonging to the attributes of the - controller""" - for ref_type, count in Counter([io.ref_type for io in ios]).items(): - if count > 1: - raise RuntimeError( - f"More than one AttributeIO class handles {ref_type.__name__}" - ) - def __repr__(self): name = self.__class__.__name__ path = ".".join(self.path) or None @@ -192,7 +175,6 @@ async def initialise(self): def post_initialise(self): """Hook to call after all attributes added, before serving the application""" self._validate_type_hints() - self._connect_attribute_ios() def _validate_type_hints(self): """Validate all type-hints were introspected""" @@ -260,28 +242,6 @@ def _validate_hinted_controller(self, name: str): sub_controller=controller, ) - def _connect_attribute_ios(self) -> None: - """Connect ``Attribute`` callbacks to ``AttributeIO``s""" - for attr in self.__attributes.values(): - ref = attr.io_ref if attr.has_io_ref() else None - if ref is None: - continue - - io = self._attribute_ref_io_map.get(type(ref)) - if io is None: - raise ValueError( - f"{self.__class__.__name__} does not have an AttributeIO " - f"to handle {attr.io_ref.__class__.__name__}" - ) - - if isinstance(attr, AttrW): - attr.set_on_put_callback(io.send) - if isinstance(attr, AttrR): - attr.set_update_callback(io.update) - - for controller in self.sub_controllers.values(): - controller._connect_attribute_ios() # noqa: SLF001 - @property def path(self) -> list[str]: """Path prefix of attributes, recursively including parent Controllers.""" diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py index a6c726027..b03793db6 100755 --- a/src/fastcs/controllers/controller.py +++ b/src/fastcs/controllers/controller.py @@ -2,9 +2,7 @@ from collections import defaultdict from collections.abc import Sequence -from fastcs.attributes import AnyAttributeIO from fastcs.attributes.attr_r import AttrR -from fastcs.attributes.attribute_io_ref import AttributeIORef from fastcs.controllers.base_controller import BaseController from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger @@ -18,9 +16,8 @@ class Controller(BaseController): def __init__( self, description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: - super().__init__(description=description, ios=ios) + super().__init__(description=description) self._connected = False def add_sub_controller(self, name: str, sub_controller: BaseController): @@ -83,14 +80,18 @@ def create_api_and_tasks( scan_dict[method.period].append(method.fn) for attribute in api.attributes.values(): - match attribute: - case AttrR(_io_ref=AttributeIORef(update_period=update_period)): - if update_period is ONCE: - initial_coros.append(attribute.bind_update_callback()) - elif update_period is not None: - scan_dict[update_period].append( - attribute.bind_update_callback() - ) + if not (isinstance(attribute, AttrR) and attribute.has_getter()): + continue + + poll_period = attribute.poll_period + + async def poll_attribute(attribute: AttrR = attribute) -> None: + await attribute.poll() + + if poll_period is ONCE: + initial_coros.append(poll_attribute) + elif poll_period is not None: + scan_dict[poll_period].append(poll_attribute) periodic_scan_coros: list[ScanCallback] = [] for period, methods in scan_dict.items(): diff --git a/src/fastcs/controllers/controller_vector.py b/src/fastcs/controllers/controller_vector.py index 119258272..739952fc2 100755 --- a/src/fastcs/controllers/controller_vector.py +++ b/src/fastcs/controllers/controller_vector.py @@ -1,6 +1,5 @@ -from collections.abc import Iterator, Mapping, MutableMapping, Sequence +from collections.abc import Iterator, Mapping, MutableMapping -from fastcs.attributes import AnyAttributeIO from fastcs.controllers.base_controller import BaseController from fastcs.controllers.controller import Controller from fastcs.util import Controller_T @@ -18,9 +17,8 @@ def __init__( self, children: Mapping[int, Controller_T], description: str | None = None, - ios: Sequence[AnyAttributeIO] | None = None, ) -> None: - super().__init__(description=description, ios=ios) + super().__init__(description=description) self._children: dict[int, Controller_T] = {} for index, child in children.items(): self[index] = child diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py index 7ebea02fe..4d473b9af 100644 --- a/src/fastcs/demo/eiger.py +++ b/src/fastcs/demo/eiger.py @@ -10,16 +10,15 @@ """ import enum -from dataclasses import KW_ONLY, dataclass +from dataclasses import dataclass from typing import Any, cast import httpx -from fastcs.attributes import AnyAttributeIO, AttributeIO, AttributeIORef, AttrR, AttrRW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.controllers import Controller from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType -from fastcs.util import ONCE _DATATYPES: dict[ValueType, type[DataType]] = { "float": Float, @@ -100,29 +99,6 @@ async def put(self, subsystem: Subsystem, param: str, value) -> None: response.raise_for_status() -@dataclass -class EigerAttributeIORef(AttributeIORef): - subsystem: Subsystem - param: str - _: KW_ONLY - update_period: float | None = ONCE - - -class EigerAttributeIO(AttributeIO[Any, EigerAttributeIORef]): - def __init__(self, connection: EigerConnection): - super().__init__() - self._connection = connection - - async def update(self, attr: AttrR[Any, EigerAttributeIORef]) -> None: - data = await self._connection.get(attr.io_ref.subsystem, attr.io_ref.param) - # No cast here - ``update`` validates against the datatype, which is the one - # place a bad value from the device should be coerced or complained about. - await attr.update(data["value"]) - - async def send(self, attr, value) -> None: - await self._connection.put(attr.io_ref.subsystem, attr.io_ref.param, value) - - class EigerDetector(Controller): """Cut-down Eiger controller: half declared, half introspected.""" @@ -144,11 +120,26 @@ def __init__( transport: httpx.AsyncBaseTransport | None = None, ) -> None: self.connection = EigerConnection(transport=transport) - ios: list[AnyAttributeIO] = [EigerAttributeIO(self.connection)] - super().__init__(ios=ios) + super().__init__() self._settings = settings or EigerConnectionSettings() + def _getter(self, subsystem: Subsystem, param: str): + async def get() -> Any: + data = await self.connection.get(subsystem, param) + # No cast here - ``update`` validates against the datatype, which is the + # one place a bad value from the device should be coerced or complained + # about. + return data["value"] + + return get + + def _setter(self, subsystem: Subsystem, param: str): + async def put(value: Any) -> None: + await self.connection.put(subsystem, param, value) + + return put + async def connect(self) -> None: await self.connection.connect(self._settings) self._connected = True @@ -163,20 +154,25 @@ async def initialise(self) -> None: datatype = _datatype(param, data) if data["access_mode"] == "rw": - io_ref = EigerAttributeIORef(subsystem=subsystem, param=param) - attr = AttrRW(datatype, io_ref=io_ref) + attr = AttrRW( + datatype, + getter=self._getter(subsystem, param), + setter=self._setter(subsystem, param), + ) else: # Read-only params are status values that change on the device, # so poll them periodically rather than reading once. - io_ref = EigerAttributeIORef( - subsystem=subsystem, param=param, update_period=UPDATE_PERIOD + attr = AttrR( + datatype, + getter=Polled( + self._getter(subsystem, param), period=UPDATE_PERIOD + ), ) - attr = AttrR(datatype, io_ref=io_ref) self.add_attribute(param, attr) # Keep the derived ``idle`` flag in sync with the introspected ``state``. - self.state.add_on_update_callback(self._update_idle) + self.state.add_readback_callback(self._update_idle) async def _update_idle(self, state: enum.Enum) -> None: await self.idle.update(state.value == "idle") diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py index f91b35e8a..756559bc8 100755 --- a/src/fastcs/demo/temperature_attr.py +++ b/src/fastcs/demo/temperature_attr.py @@ -1,18 +1,23 @@ """Example 2 - getter/setter: per-attribute IO wired via callables in ``__init__``. -Baseline against the CURRENT callback-IO API. A **single** generic IO class -(``TemperatureIO``) drives every attribute; the per-attribute behaviour lives in -each attribute's ``TemperatureIORef``, which just carries the command-building -callables (``read_cmd``/``write_cmd``) taken from a protocol class with one method -per device command. This is the honest precursor to the -``AttrRW(getter=..., setter=...)`` constructor params landing in #392: -``read_cmd``/``write_cmd`` *are* the getter/setter, and #392 simply promotes them -onto the constructor and deletes this IO/ref wrapper, while the protocol classes -survive unchanged. - -Because the attributes are wired in ``__init__`` rather than the class body, each -one can close over per-instance state - which is what lets a ramp's index be baked -into its protocol instead of dispatched on at IO time. This module also carries the +The device's protocol is written as a plain class with one ``async`` method per +command, each doing its own IO and returning a typed value - the shape a +manufacturer's own library usually already has. Those methods *are* the getters and +setters:: + + self.ramp_rate = AttrRW( + getter=Polled(protocol.get_ramp_rate, period=0.2), + setter=protocol.set_ramp_rate, + ) + +Nothing sits between the protocol and the attribute: no IO class hierarchy, no +per-attribute ref object, no adapter. Because each method annotates its types, the +datatype is inferred from them, so most attributes do not restate it - only the ones +that want metadata the annotation cannot carry, like ``Float(prec=3)``. + +Because the attributes are wired in ``__init__`` rather than the class body, each one +can close over per-instance state - which is what lets a ramp's index be baked into +its protocol instead of dispatched on at IO time. This module also carries the composition and methods rungs: a ``ControllerVector`` of ``TemperatureRampController`` sub-controllers, plus ``@scan`` and ``@command``. """ @@ -21,20 +26,17 @@ import enum import json from collections.abc import Callable -from dataclasses import KW_ONLY, dataclass -from typing import Any, TypeVar +from dataclasses import dataclass import numpy as np -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Enum, Float, Int, Waveform +from fastcs.datatypes import DType_T, Float, Waveform from fastcs.logging import logger from fastcs.methods import command, scan -NumberT = TypeVar("NumberT", int, float) - class OnOffEnum(enum.StrEnum): Off = "0" @@ -48,120 +50,95 @@ class TemperatureControllerSettings: class TemperatureProtocol: - """The device wire protocol - one method per command, referenced by the IORefs. + """The device's wire protocol - one async method per command, doing its own IO. - Each getter returns the query string to send; each setter returns the command - string to send for a given value. These are exactly the callables #392 will pass - straight to ``AttrRW(getter=..., setter=...)``. + This is the layer a manufacturer would ship: it knows how to talk to the device + and nothing about FastCS. Each method is a zero- or one-argument coroutine + returning an annotated type, which is exactly what an attribute's ``getter`` and + ``setter`` are, so they can be handed over as-is. """ - def get_ramp_rate(self) -> str: - return "R?\r\n" - - def set_ramp_rate(self, value: float) -> str: - return f"R={value}\r\n" - - def get_power(self) -> str: - return "P?\r\n" - - def get_voltages(self) -> str: - return "V?\r\n" - - -class TemperatureRampProtocol: - """The wire protocol of a single ramp, whose commands are suffixed by its index. - - The index is baked into the instance, so every command is still a zero- or - one-argument callable that can be handed to an attribute as-is. - """ - - def __init__(self, index: int) -> None: - self.suffix = f"{index:02d}" - - def get_start(self) -> str: - return f"S{self.suffix}?\r\n" + def __init__(self, connection: IPConnection, suffix: str = "") -> None: + self._connection = connection + self._suffix = suffix - def set_start(self, value: int) -> str: - return f"S{self.suffix}={value}\r\n" + async def _query(self, param: str, dtype: Callable[[str], DType_T]) -> DType_T: + query = f"{param}{self._suffix}?\r\n" + response = (await self._connection.send_query(query)).strip("\r\n") + logger.trace("Query for attribute", query=query, response=response) + return dtype(response) - def get_end(self) -> str: - return f"E{self.suffix}?\r\n" + async def _command(self, param: str, value: object) -> None: + command = f"{param}{self._suffix}={value}\r\n" + await self._connection.send_command(command) + logger.trace("Send command for attribute", command=command) - def set_end(self, value: int) -> str: - return f"E{self.suffix}={value}\r\n" + async def get_ramp_rate(self) -> float: + return await self._query("R", float) - def get_enabled(self) -> str: - return f"N{self.suffix}?\r\n" + async def set_ramp_rate(self, value: float) -> None: + await self._command("R", value) - def set_enabled(self, value: OnOffEnum) -> str: - return f"N{self.suffix}={value}\r\n" + async def get_power(self) -> float: + return await self._query("P", float) - def get_target(self) -> str: - return f"T{self.suffix}?\r\n" + async def get_voltages(self) -> np.ndarray: + query = "V?\r\n" + response = (await self._connection.send_query(query)).strip("\r\n") + logger.trace("Query for attribute", query=query, response=response) + return np.array(json.loads(response), dtype=np.int32) - def get_actual(self) -> str: - return f"A{self.suffix}?\r\n" +class TemperatureRampProtocol(TemperatureProtocol): + """The protocol of a single ramp, whose commands are suffixed by its index. -@dataclass -class TemperatureIORef(AttributeIORef): - """Per-attribute IO spec: the command-building callables for one attribute.""" + The index is baked into the instance, so every command is still a zero- or + one-argument callable that can be handed to an attribute as-is - no dispatching + on which ramp is being addressed at IO time. + """ - read_cmd: Callable[[], str] - write_cmd: Callable[[Any], str] | None = None - _: KW_ONLY - update_period: float | None = 0.2 + def __init__(self, connection: IPConnection, index: int) -> None: + super().__init__(connection, suffix=f"{index:02d}") + async def get_start(self) -> int: + return await self._query("S", int) -class TemperatureIO(AttributeIO[NumberT, TemperatureIORef]): - """A single generic IO shared by every attribute; behaviour comes from the ref.""" + async def set_start(self, value: int) -> None: + await self._command("S", value) - def __init__(self, connection: IPConnection): - super().__init__() + async def get_end(self) -> int: + return await self._query("E", int) - self._connection = connection + async def set_end(self, value: int) -> None: + await self._command("E", value) - async def update(self, attr: AttrR[NumberT, TemperatureIORef]) -> None: - query = attr.io_ref.read_cmd() - response = (await self._connection.send_query(query)).strip("\r\n") - self.log_event( - "Query for attribute", - topic=attr, - query=query, - response=response, - ) + async def get_enabled(self) -> OnOffEnum: + return await self._query("N", OnOffEnum) - await attr.update(attr.dtype(response)) + async def set_enabled(self, value: OnOffEnum) -> None: + await self._command("N", value) - async def send( - self, attr: AttrW[NumberT, TemperatureIORef], value: NumberT - ) -> None: - if attr.io_ref.write_cmd is None: - raise TypeError(f"{attr} is read-only: no write_cmd on its io_ref") + async def get_target(self) -> float: + return await self._query("T", float) - command = attr.io_ref.write_cmd(value) - await self._connection.send_command(command) - self.log_event("Send command for attribute", topic=attr, command=command) + async def get_actual(self) -> float: + return await self._query("A", float) class TemperatureController(Controller): def __init__(self, settings: TemperatureControllerSettings) -> None: self.connection = IPConnection() self._settings = settings - self._protocol = TemperatureProtocol() + self._protocol = TemperatureProtocol(self.connection) - super().__init__(ios=[TemperatureIO(self.connection)]) + super().__init__() + # No datatype: inferred from get_ramp_rate's `-> float` annotation. self.ramp_rate = AttrRW( - Float(), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_ramp_rate, - write_cmd=self._protocol.set_ramp_rate, - ), - ) - self.power = AttrR( - Float(), io_ref=TemperatureIORef(read_cmd=self._protocol.get_power) + getter=Polled(self._protocol.get_ramp_rate, period=0.2), + setter=self._protocol.set_ramp_rate, ) + self.power = AttrR(getter=Polled(self._protocol.get_power, period=0.2)) # Updated by the update_voltages scan below, so no IO of its own self.voltages = AttrR(Waveform(np.int32, shape=(4,))) @@ -175,7 +152,7 @@ def __init__(self, settings: TemperatureControllerSettings) -> None: @command() async def cancel_all(self) -> None: for rc in self.ramps.values(): - await rc.enabled.put(OnOffEnum.Off, sync_setpoint=True) + await rc.enabled.set(OnOffEnum.Off) # TODO: The requests all get concatenated and the sim doesn't handle it await asyncio.sleep(0.1) @@ -197,57 +174,46 @@ async def close(self) -> None: @scan(0.1) async def update_voltages(self): - query = self._protocol.get_voltages() - voltages = json.loads((await self.connection.send_query(query)).strip("\r\n")) + voltages = await self._protocol.get_voltages() await self.voltages.update(voltages) for index, controller in self.ramps.items(): self.log_event( - "Update voltages", - topic=controller.voltage, - query=query, - response=voltages, + "Update voltages", topic=controller.voltage, response=voltages ) await controller.voltage.update(float(voltages[index - 1])) class TemperatureRampController(Controller): def __init__(self, index: int, conn: IPConnection) -> None: - self._protocol = TemperatureRampProtocol(index) + self._protocol = TemperatureRampProtocol(conn, index) - super().__init__(f"Ramp{self._protocol.suffix}", ios=[TemperatureIO(conn)]) + super().__init__(f"Ramp{index:02d}") self.connection = conn + # Datatypes inferred from the protocol methods' annotations - including the + # enum, whose members come from OnOffEnum via get_enabled's return type. self.start = AttrRW( - Int(), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_start, - write_cmd=self._protocol.set_start, - ), + getter=Polled(self._protocol.get_start, period=0.2), + setter=self._protocol.set_start, ) self.end = AttrRW( - Int(), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_end, - write_cmd=self._protocol.set_end, - ), + getter=Polled(self._protocol.get_end, period=0.2), + setter=self._protocol.set_end, ) self.enabled = AttrRW( - Enum(OnOffEnum), - io_ref=TemperatureIORef( - read_cmd=self._protocol.get_enabled, - write_cmd=self._protocol.set_enabled, - ), + getter=Polled(self._protocol.get_enabled, period=0.2), + setter=self._protocol.set_enabled, ) + # Stated explicitly, to carry metadata the annotation cannot: `-> float` + # says nothing about display precision. self.target = AttrR( - Float(prec=3), - io_ref=TemperatureIORef(read_cmd=self._protocol.get_target), + Float(prec=3), getter=Polled(self._protocol.get_target, period=0.2) ) self.actual = AttrR( - Float(prec=3), - io_ref=TemperatureIORef(read_cmd=self._protocol.get_actual), + Float(prec=3), getter=Polled(self._protocol.get_actual, period=0.2) ) # Updated by the parent controller's update_voltages scan self.voltage = AttrR(Float(prec=3)) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 536cdcfa5..29a89d3a2 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -214,7 +214,7 @@ async def async_record_set(value: DType_T): _add_attr_pvi_info(record, pv_prefix, attr_name, "r") - attribute.add_on_update_callback(async_record_set) + attribute.add_readback_callback(async_record_set) def _create_and_link_write_pv( @@ -229,7 +229,7 @@ def _create_and_link_write_pv( async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) - await attribute.put(cast_from_epics_type(attribute.datatype, value)) + await attribute.set(cast_from_epics_type(attribute.datatype, value)) async def set_setpoint_without_process(value: DType_T): tracer.log_event( @@ -244,7 +244,10 @@ async def set_setpoint_without_process(value: DType_T): _add_attr_pvi_info(record, pv_prefix, attr_name, "w") - attribute.add_sync_setpoint_callback(set_setpoint_without_process) + # Mirror the attribute's setpoint whenever it changes, however it changed - + # a put on this PV, a put on another transport, or the device reporting its + # own setpoint. See ADR 0020. + attribute.add_setpoint_callback(set_setpoint_without_process) def _create_and_link_command_pvs( diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py index 6a3e6dd83..c6473afbf 100644 --- a/src/fastcs/transports/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -73,7 +73,7 @@ def validate_ca_id(controller_api: ControllerAPI) -> None: def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper: common_fields = { "DESC": attribute.description, - "initial_value": cast_to_epics_type(attribute.datatype, attribute.get()), + "initial_value": cast_to_epics_type(attribute.datatype, attribute.readback), } match attribute.datatype: @@ -139,7 +139,7 @@ def _make_out_record(pv: str, attribute: AttrW, on_update: Callable) -> RecordWr "DESC": attribute.description, "initial_value": cast_to_epics_type( attribute.datatype, - attribute.get() + attribute.readback if isinstance(attribute, AttrRW) else attribute.datatype.initial_value, ), diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 5ba819f98..5668ce0d4 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -53,7 +53,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): else: pv.post(value) - await self._attr_w.put(cast_value) + await self._attr_w.set(cast_value) op.done() @@ -121,7 +121,7 @@ def _wrap(value: dict): def make_shared_read_pv(attribute: AttrR) -> SharedPV: shared_pv = SharedPV( - initial=cast_to_p4p_value(attribute, attribute.get()), + initial=cast_to_p4p_value(attribute, attribute.readback), **_make_shared_pv_arguments(attribute), ) @@ -129,7 +129,7 @@ async def set_readback(value): tracer.log_event("PV set readback", topic=attribute, value=value) shared_pv.post(cast_to_p4p_value(attribute, value)) - attribute.add_on_update_callback(set_readback) + attribute.add_readback_callback(set_readback) return shared_pv @@ -145,7 +145,10 @@ async def set_setpoint(value): tracer.log_event("PV set setpoint", topic=attribute, value=value) shared_pv.post(cast_to_p4p_value(attribute, value)) - attribute.add_sync_setpoint_callback(set_setpoint) + # Mirror the attribute's setpoint whenever it changes, however it changed - a + # put on this PV, a put on another transport, or the device reporting its own + # setpoint. See ADR 0020. + attribute.add_setpoint_callback(set_setpoint) return shared_pv diff --git a/src/fastcs/transports/epics/pva/ioc.py b/src/fastcs/transports/epics/pva/ioc.py index 5b2e29611..a3816fe77 100644 --- a/src/fastcs/transports/epics/pva/ioc.py +++ b/src/fastcs/transports/epics/pva/ioc.py @@ -11,7 +11,7 @@ from .pvi import add_pvi_info -async def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider: +def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider: """Parses `Attribute` s into p4p signals in handlers.""" provider = StaticProvider(pv_prefix_from_path(root_controller_api.path)) @@ -56,13 +56,16 @@ class P4PIOC: def __init__(self, controller_apis: list[ControllerAPI]): self._controller_apis = controller_apis - - async def _build_providers(self) -> list[StaticProvider]: - return [await parse_attributes(api) for api in self._controller_apis] + # Build the PVs up front rather than in ``run``. Creating a PV is what + # registers its readback/setpoint callbacks on the attribute, and ``run`` + # is awaited after the initial polls have already fired - so a PV built + # there would miss the first readback, and the setpoint an ``AttrRW`` + # seeds from it (ADR 0020), leaving the served value at the datatype + # default. ``EpicsCAIOC`` builds its records in ``__init__`` for the + # same reason. + self._providers = [parse_attributes(api) for api in self._controller_apis] async def run(self): - providers = await self._build_providers() - endless_event = asyncio.Event() - with Server(providers): + with Server(self._providers): await endless_event.wait() diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index 871e5c905..0c74ad27d 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -144,7 +144,7 @@ def _wrap_attr_set( """Wrap an attribute in a function with annotations for strawberry""" async def _dynamic_f(value): - await attribute.put(value) + await attribute.set(value) return value # Add type annotations for validation, schema, conversions @@ -161,7 +161,7 @@ def _wrap_attr_get( """Wrap an attribute in a function with annotations for strawberry""" async def _dynamic_f() -> DType_T: - return attribute.get() + return attribute.readback _dynamic_f.__name__ = attr_name _dynamic_f.__annotations__["return"] = attribute.datatype.dtype diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index 522246b2e..a8ca359d8 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -69,7 +69,7 @@ def _wrap_attr_put( attribute: AttrW[DType_T], ) -> Callable[[DType_T], Coroutine[Any, Any, None]]: async def attr_put(request): - await attribute.put(cast_from_rest_type(attribute.datatype, request.value)) + await attribute.set(cast_from_rest_type(attribute.datatype, request.value)) # Fast api uses type annotations for validation, schema, conversions attr_put.__annotations__["request"] = _put_request_body(attribute) @@ -95,7 +95,7 @@ def _wrap_attr_get( attribute: AttrR[DType_T], ) -> Callable[[], Coroutine[Any, Any, dict[str, object]]]: async def attr_get() -> dict[str, object]: - value = attribute.get() + value = attribute.readback return {"value": cast_to_rest_type(attribute.datatype, value)} return attr_get diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 93aaab1c8..553fe3d92 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -30,7 +30,7 @@ def _wrap_updater_fget( ) -> Callable[[Any], Any]: async def fget(tango_device: Device): tango_device.info_stream(f"called fget method: {attr_name}") - return cast_to_tango_type(attribute.datatype, attribute.get()) + return cast_to_tango_type(attribute.datatype, attribute.readback) return fget @@ -54,7 +54,7 @@ def _wrap_updater_fset( ) -> Callable[[Any, Any], Any]: async def fset(tango_device: Device, value): tango_device.info_stream(f"called fset method: {attr_name}") - coro = attribute.put(cast_from_tango_type(attribute.datatype, value)) + coro = attribute.set(cast_from_tango_type(attribute.datatype, value)) await _run_threadsafe_blocking(coro, loop) return fset diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index c57916134..d429abd7a 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -1,44 +1,24 @@ import copy from contextlib import contextmanager -from dataclasses import dataclass from typing import Literal from pytest_mock import MockerFixture, MockType -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import DType_T, Int +from fastcs.datatypes import Int from fastcs.methods import command, scan -@dataclass -class MyTestAttributeIORef(AttributeIORef): - update_period = 1 - - -class MyTestAttributeIO(AttributeIO[DType_T, MyTestAttributeIORef]): - async def update(self, attr: AttrR[DType_T, MyTestAttributeIORef]): - print(f"update {attr}") - - async def send(self, attr: AttrW[DType_T, MyTestAttributeIORef], value: DType_T): - print(f"sending {attr} = {value}") - if isinstance(attr, AttrRW): - await attr.update(value) - - -test_attribute_io = MyTestAttributeIO() # instance - - class TestSubController(Controller): - read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) - def __init__(self) -> None: - super().__init__(ios=[test_attribute_io]) + super().__init__() + self.read_int = AttrR(Int()) class MyTestController(Controller): def __init__(self) -> None: - super().__init__(ios=[test_attribute_io]) + super().__init__() self._sub_controllers: list[TestSubController] = [] for index in range(1, 3): @@ -97,29 +77,67 @@ def __init__( @contextmanager def assert_read_here(self, path: list[str]): - yield from self._assert_method(path, "get") + yield from self._assert_readback(path) @contextmanager def assert_write_here(self, path: list[str]): - yield from self._assert_method(path, "put") + yield from self._assert_method(path, "set") @contextmanager def assert_execute_here(self, path: list[str]): yield from self._assert_method(path, "") - def _assert_method(self, path: list[str], method: Literal["get", "put", ""]): + def _navigate(self, path: list[str]) -> tuple[ControllerAPI, str]: + queue = copy.deepcopy(path) + controller_api: ControllerAPI = self + item_name = queue.pop(-1) + for item in queue: + controller_api = controller_api.sub_apis[item] + return controller_api, item_name + + def _assert_readback(self, path: list[str]): + """Confirm that an attribute's ``readback`` property is read exactly once + within a context block. + + ``readback`` is a read-only property, so it can't be spied on with + ``mocker.spy`` (which needs to reassign the instance attribute). Instead, + temporarily replace the property on the attribute's class with a counting + wrapper, scoped to just this one instance. + """ + controller_api, item_name = self._navigate(path) + attr = controller_api.attributes[item_name] + assert isinstance(attr, AttrR) + cls = type(attr) + original = cls.readback + assert original.fget is not None + original_fget = original.fget + call_count = {"n": 0} + + def fget(self): + if self is attr: + call_count["n"] += 1 + return original_fget(self) + + cls.readback = property(fget) # type: ignore[misc] + try: + yield # Enter context + except Exception as e: + raise e + else: # Exit context + assert call_count["n"] == 1, ( + f"Expected {'.'.join(path + ['readback'])} to be read once, " + f"but it was read {call_count['n']} times." + ) + finally: + cls.readback = original # type: ignore[misc] + + def _assert_method(self, path: list[str], method: Literal["set", ""]): """ This context manager can be used to confirm that a fastcs controller's respective attribute or command methods are called a single time within a context block """ - queue = copy.deepcopy(path) - - # Navigate to sub controller - controller_api = self - item_name = queue.pop(-1) - for item in queue: - controller_api = controller_api.sub_apis[item] + controller_api, item_name = self._navigate(path) # Get spy if method: diff --git a/tests/conftest.py b/tests/conftest.py index 818c7d178..9c6563dfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ from fastcs.logging._logging import LogLevel from fastcs.transports.tango.dsr import FASTCS_TANGO_SERVER_NAME, register_dev from fastcs.transports.tango.util import tango_dev_class_name, tango_dev_name -from tests.assertable_controller import MyTestAttributeIORef, MyTestController +from tests.assertable_controller import MyTestController from tests.example_p4p_ioc import run as _run_p4p_ioc from tests.example_softioc import run as _run_softioc @@ -42,11 +42,11 @@ def clear_softioc_records(): class BackendTestController(MyTestController): - read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int: AttrRW = AttrRW(Int(), io_ref=MyTestAttributeIORef()) + read_int: AttrR = AttrR(Int()) + read_write_int: AttrRW = AttrRW(Int()) read_write_float: AttrRW = AttrRW(Float()) read_bool: AttrR = AttrR(Bool()) - write_bool: AttrW = AttrW(Bool(), io_ref=MyTestAttributeIORef()) + write_bool: AttrW = AttrW(Bool()) read_string: AttrRW = AttrRW(String()) diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py index 17c76be8c..df35f758d 100644 --- a/tests/demo/test_eiger.py +++ b/tests/demo/test_eiger.py @@ -51,9 +51,9 @@ async def test_hinted_attributes_are_introspected(detector: EigerDetector): @pytest.mark.asyncio async def test_enum_attribute_reads_as_member(detector: EigerDetector, sim: SimState): sim["status"]["state"].value = "acquire" - await detector.state.bind_update_callback()() + await detector.state.poll() - state = detector.state.get() + state = detector.state.readback assert isinstance(state, enum.Enum) assert state.value == "acquire" @@ -66,37 +66,37 @@ async def test_unhinted_attributes_are_also_introspected(detector: EigerDetector @pytest.mark.asyncio async def test_read_attribute_from_device(detector: EigerDetector): - await detector.count_time.bind_update_callback()() - assert detector.count_time.get() == 0.1 + await detector.count_time.poll() + assert detector.count_time.readback == 0.1 humidity = detector.attributes["humidity"] assert isinstance(humidity, AttrR) - await humidity.bind_update_callback()() - assert humidity.get() == 32.1 + await humidity.poll() + assert humidity.readback == 32.1 @pytest.mark.asyncio async def test_write_attribute_to_device(detector: EigerDetector): - await detector.count_time.put(0.5) + await detector.count_time.set(0.5) # Read it back through the attribute to confirm the round-trip to the device. - await detector.count_time.bind_update_callback()() - assert detector.count_time.get() == 0.5 + await detector.count_time.poll() + assert detector.count_time.readback == 0.5 @pytest.mark.asyncio async def test_idle_derived_from_state(detector: EigerDetector, sim: SimState): # ``idle`` is soft and starts at its default, tracking ``state`` once polled. - assert detector.idle.get() is False + assert detector.idle.readback is False # Poke the read-only ``state`` via the sim backdoor, then poll the attribute. sim["status"]["state"].value = "acquire" - await detector.state.bind_update_callback()() - assert detector.idle.get() is False + await detector.state.poll() + assert detector.idle.readback is False sim["status"]["state"].value = "idle" - await detector.state.bind_update_callback()() - assert detector.idle.get() is True + await detector.state.poll() + assert detector.idle.readback is True @pytest.mark.asyncio @@ -104,9 +104,9 @@ async def test_read_only_params_poll_but_rw_read_once(detector: EigerDetector): for name in ("state", "temperature", "humidity", "description"): attr = detector.attributes[name] assert isinstance(attr, AttrR) and not isinstance(attr, AttrRW) - assert attr.io_ref.update_period == UPDATE_PERIOD + assert attr.poll_period == UPDATE_PERIOD - assert detector.count_time.io_ref.update_period is ONCE + assert detector.count_time.poll_period is ONCE @pytest.mark.asyncio @@ -129,11 +129,11 @@ async def test_temperature_oscillation_seen_via_subscribe(): async def record(value: float) -> None: seen.append(value) - temperature.add_on_update_callback(record) + temperature.add_readback_callback(record) # Poll across several sim flips (every 0.5s) so the value changes under us. for _ in range(8): - await temperature.bind_update_callback()() + await temperature.poll() await asyncio.sleep(0.2) await controller.disconnect() diff --git a/tests/demo/test_temperature_attr.py b/tests/demo/test_temperature_attr.py index 437034209..bab260065 100644 --- a/tests/demo/test_temperature_attr.py +++ b/tests/demo/test_temperature_attr.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from fastcs.attributes import AttrW from fastcs.connections import IPConnectionSettings from fastcs.controllers import ControllerVector from fastcs.demo.temperature_attr import ( @@ -41,17 +42,17 @@ def test_ramps_is_controller_vector(controller: TemperatureController): async def test_ramp_rate_read_from_device(controller: TemperatureController): controller.connection.send_query = AsyncMock(return_value="1.5\r\n") - await controller.ramp_rate.bind_update_callback()() + await controller.ramp_rate.poll() controller.connection.send_query.assert_awaited_once_with("R?\r\n") - assert controller.ramp_rate.get() == 1.5 + assert controller.ramp_rate.readback == 1.5 @pytest.mark.asyncio async def test_ramp_rate_written_to_device(controller: TemperatureController): controller.connection.send_command = AsyncMock() - await controller.ramp_rate.put(2.5) + await controller.ramp_rate.set(2.5) controller.connection.send_command.assert_awaited_once_with("R=2.5\r\n") @@ -60,27 +61,27 @@ async def test_ramp_rate_written_to_device(controller: TemperatureController): async def test_power_read_from_device(controller: TemperatureController): controller.connection.send_query = AsyncMock(return_value="10.25\r\n") - await controller.power.bind_update_callback()() + await controller.power.poll() controller.connection.send_query.assert_awaited_once_with("P?\r\n") - assert controller.power.get() == 10.25 + assert controller.power.readback == 10.25 @pytest.mark.asyncio async def test_ramp_start_read_from_device(ramp_controller: TemperatureRampController): ramp_controller.connection.send_query = AsyncMock(return_value="7\r\n") - await ramp_controller.start.bind_update_callback()() + await ramp_controller.start.poll() ramp_controller.connection.send_query.assert_awaited_once_with("S01?\r\n") - assert ramp_controller.start.get() == 7 + assert ramp_controller.start.readback == 7 @pytest.mark.asyncio async def test_ramp_end_written_to_device(ramp_controller: TemperatureRampController): ramp_controller.connection.send_command = AsyncMock() - await ramp_controller.end.put(42) + await ramp_controller.end.set(42) ramp_controller.connection.send_command.assert_awaited_once_with("E01=42\r\n") @@ -91,7 +92,7 @@ async def test_ramp_enabled_written_to_device( ): ramp_controller.connection.send_command = AsyncMock() - await ramp_controller.enabled.put(OnOffEnum.On) + await ramp_controller.enabled.set(OnOffEnum.On) ramp_controller.connection.send_command.assert_awaited_once_with("N01=1\r\n") @@ -101,7 +102,7 @@ async def test_each_ramp_addresses_its_own_index(controller: TemperatureControll controller.connection.send_command = AsyncMock() for index, ramp in controller.ramps.items(): - await ramp.start.put(index) + await ramp.start.set(index) assert [ call.args[0] for call in controller.connection.send_command.await_args_list @@ -109,23 +110,25 @@ async def test_each_ramp_addresses_its_own_index(controller: TemperatureControll @pytest.mark.asyncio -async def test_read_only_attribute_has_no_write_command( +async def test_read_only_attribute_has_no_setter( ramp_controller: TemperatureRampController, ): - assert ramp_controller.target.io_ref.write_cmd is None + # Access mode is structural now: no setter means it is not an AttrW at all. + assert not isinstance(ramp_controller.target, AttrW) + assert ramp_controller.start.has_setter() @pytest.mark.asyncio async def test_cancel_all_disables_every_ramp(controller: TemperatureController): - puts = {} + sets = {} for index, ramp in controller.ramps.items(): - puts[index] = AsyncMock() - ramp.enabled.put = puts[index] # type: ignore[method-assign] + sets[index] = AsyncMock() + ramp.enabled.set = sets[index] # type: ignore[method-assign] await controller.cancel_all() - for put in puts.values(): - put.assert_awaited_once_with(OnOffEnum.Off, sync_setpoint=True) + for set_ in sets.values(): + set_.assert_awaited_once_with(OnOffEnum.Off) @pytest.mark.asyncio @@ -138,7 +141,7 @@ async def test_update_voltages_updates_waveform_and_each_ramp( controller.connection.send_query.assert_awaited_once_with("V?\r\n") np.testing.assert_array_equal( - controller.voltages.get(), np.array([1, 2, 3, 4], dtype=np.int32) + controller.voltages.readback, np.array([1, 2, 3, 4], dtype=np.int32) ) for index, ramp in controller.ramps.items(): - assert ramp.voltage.get() == pytest.approx(float(index)) + assert ramp.voltage.readback == pytest.approx(float(index)) diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 95cc8e70b..ce83fe43f 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -1,28 +1,16 @@ import asyncio import enum -from dataclasses import dataclass import numpy as np -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, Table, Waveform +from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS from fastcs.methods import command, scan from fastcs.transports.epics.pva import EpicsPVATransport -@dataclass -class SimpleAttributeIORef(AttributeIORef): - pass - - -class SimpleAttributeIO(AttributeIO[DType_T, SimpleAttributeIORef]): - async def send(self, attr: AttrW[DType_T, SimpleAttributeIORef], value): - if isinstance(attr, AttrRW): - await attr.update(value) - - class FEnum(enum.Enum): A = 0 B = 1 @@ -33,39 +21,47 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" - a: AttrRW = AttrRW( - Int(max=400_000, max_alarm=40_000), io_ref=SimpleAttributeIORef() - ) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5), io_ref=SimpleAttributeIORef()) + a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) + b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) table: AttrRW = AttrRW( Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]), - io_ref=SimpleAttributeIORef(), ) - def __init__(self, description=None, ios=None): - super().__init__(description, ios) - class ChildController(Controller): fail_on_next_e = True - c: AttrW = AttrW(Int(), io_ref=SimpleAttributeIORef()) + c: AttrW = AttrW(Int()) + + def __init__(self, description: str | None = None): + super().__init__(description=description) + + # A getter/setter pair against an in-memory "device", doing what an + # AttributeIO used to do. The setter clamps the requested value and + # returns what it accepted, which becomes both the readback and the + # setpoint; the getter seeds the setpoint when the controller connects. + self._clamped = 5 + self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped) + + async def get_clamped(self) -> int: + return self._clamped - def __init__(self, description=None, ios=None): - super().__init__(description, ios) + async def set_clamped(self, value: int) -> int: + self._clamped = min(max(value, 0), 100) + return self._clamped @command() async def d(self): print("D: RUNNING") await asyncio.sleep(0.1) print("D: FINISHED") - await self.j.update(self.j.get() + 1) + await self.j.update(self.j.readback + 1) - e: AttrR = AttrR(Bool(), io_ref=SimpleAttributeIORef()) + e: AttrR = AttrR(Bool()) @scan(1) async def flip_flop(self): - await self.e.update(not self.e.get()) + await self.e.update(not self.e.readback) f: AttrRW = AttrRW(Enum(FEnum)) g: AttrRW = AttrRW(Waveform(np.int64, shape=(3,))) @@ -81,15 +77,14 @@ async def i(self): else: self.fail_on_next_e = True print("I: FINISHED") - await self.j.update(self.j.get() + 1) + await self.j.update(self.j.readback + 1) j: AttrR = AttrR(Int()) def run(id="P4P_TEST_DEVICE"): - simple_attribute_io = SimpleAttributeIO() p4p_options = EpicsPVATransport() - controller = ParentController(ios=[simple_attribute_io]) + controller = ParentController() controller.set_path([id]) class ChildVector(ControllerVector): @@ -100,12 +95,8 @@ def __init__(self, children, description=None): sub_controller = ChildVector( { - 1: ChildController( - description="some sub controller", ios=[simple_attribute_io] - ), - 2: ChildController( - description="another sub controller", ios=[simple_attribute_io] - ), + 1: ChildController(description="some sub controller"), + 2: ChildController(description="another sub controller"), }, description="some child vector", ) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 14c1a0278..d58011d71 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -16,6 +16,18 @@ class ParentController(Controller): a: AttrR = AttrR(Int()) b: AttrRW = AttrRW(Int()) + def __init__(self, description: str | None = None) -> None: + super().__init__(description) + self._clamped = 5 + self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped) + + async def get_clamped(self) -> int: + return self._clamped + + async def set_clamped(self, value: int) -> int: + self._clamped = min(max(value, 0), 100) + return self._clamped + class ChildController(Controller): c: AttrW = AttrW(Int()) @@ -30,7 +42,7 @@ def run(id="SOFTIOC_TEST_DEVICE"): controller.set_path([id]) vector = ControllerVector({i: ChildController() for i in range(2)}) controller.add_sub_controller("ChildVector", vector) - gui_options = EpicsGUIOptions(output_dir=Path("."), title="Demo Vector") + gui_options = EpicsGUIOptions(output_dir=Path("./opis"), title="Demo Vector") fastcs = FastCS( controller, [ diff --git a/tests/test_attribute_logging.py b/tests/test_attribute_logging.py index e24a47db4..4f54d5d3b 100644 --- a/tests/test_attribute_logging.py +++ b/tests/test_attribute_logging.py @@ -33,7 +33,7 @@ async def test_attr_r_update_logs_validation_error(loguru_caplog): attr = AttrR(Int()) with pytest.raises(ValueError): - await attr.update("not_an_int") + await attr.update("not_an_int") # type: ignore[arg-type] assert "Failed to validate value" in loguru_caplog.text @@ -45,9 +45,9 @@ async def test_attr_r_update_logs_callback_failure(loguru_caplog): async def failing_callback(_value: int): raise RuntimeError("callback failed") - attr.add_on_update_callback(failing_callback) + attr.add_readback_callback(failing_callback) with pytest.raises(RuntimeError): await attr.update(42) - assert "On update callbacks failed" in loguru_caplog.text + assert "Readback callbacks failed" in loguru_caplog.text diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cdd428911..e78d5c59e 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,14 +1,13 @@ import asyncio -from dataclasses import dataclass from functools import partial -from typing import Generic, TypeVar import pytest from pytest_mock import MockerFixture -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW, NotPolled, Polled, Update from fastcs.controllers import Controller from fastcs.datatypes import Float, Int, String +from fastcs.util import ONCE def test_attribute_access_mode(): @@ -26,10 +25,8 @@ def test_attribute_access_mode(): def test_attr_r(): attr = AttrR(String(), group="test group") - with pytest.raises(RuntimeError): - _ = attr.io_ref - - assert not attr.has_io_ref() + assert not attr.has_getter() + assert attr.poll_period is None assert isinstance(attr.datatype, String) assert attr.dtype == str assert attr.group == "test group" @@ -42,39 +39,123 @@ def test_attr_r(): assert attr.name == "test_name" assert attr.path == ["test_path"] - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="already registered with a controller as"): attr.set_name("test_name") - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="already registered with a controller at"): attr.set_path(["test_path"]) - assert attr.get() == "" + assert attr.readback == "" + + +def test_datatype_inferred_from_getter_annotation(): + async def get_value() -> float: + return 1.5 + + attr = AttrR(getter=get_value) + assert isinstance(attr.datatype, Float) + + +def test_datatype_inferred_from_setter_annotation(): + async def set_value(value: int) -> None: + pass + + attr = AttrW(setter=set_value) + assert isinstance(attr.datatype, Int) + + +def test_datatype_required_when_not_inferable(): + expected_message = "datatype must be given explicitly" + + with pytest.raises(ValueError, match=expected_message): + AttrR() + + with pytest.raises(ValueError, match=expected_message): + AttrW() + + with pytest.raises(ValueError, match=expected_message): + AttrRW() @pytest.mark.asyncio -async def test_attr_update(mocker: MockerFixture): +async def test_attr_update(): attr = AttrRW(Int()) await attr.update(42) - assert attr.get() == 42 + assert attr.readback == 42 await attr.update("100") # type: ignore - assert attr.get() == 100 + assert attr.readback == 100 with pytest.raises(ValueError, match="Failed to cast"): await attr.update("not_an_int") # type: ignore - attr = AttrRW(Int()) - sync_setpoint_mock = mocker.AsyncMock() - attr.add_sync_setpoint_callback(sync_setpoint_mock) + # update() also accepts an Update wrapper, unwrapping to just the value + await attr.update(Update(7, timestamp=123.0)) + assert attr.readback == 7 + + +@pytest.mark.asyncio +async def test_poll(): + async def do_update(): + return 5 + + attr = AttrR(Int(), getter=do_update) + assert attr.has_getter() + + value = await attr.poll() + assert value == 5 + assert attr.readback == 5 + + +@pytest.mark.asyncio +async def test_poll_unwraps_update_wrapper(): + async def do_update(): + return Update(9, timestamp=123.0) + + attr = AttrR(Int(), getter=do_update) + value = await attr.poll() + assert value == 9 + assert attr.readback == 9 + + +@pytest.mark.asyncio +async def test_poll_with_no_getter_raises(): + attr = AttrR(Int()) + + with pytest.raises(RuntimeError, match="has no getter"): + await attr.poll() + + +@pytest.mark.asyncio +async def test_poll_exception_propagates(): + async def do_update(): + raise ValueError("do_update failed") + + attr = AttrR(Int(), getter=do_update) - await attr.update("200") # type: ignore - assert attr.get() == 200 - sync_setpoint_mock.assert_called_once_with(200) + with pytest.raises(ValueError, match="do_update failed"): + await attr.poll() - sync_setpoint_mock.reset_mock() - await attr.update(20) - assert attr.get() == 20 - sync_setpoint_mock.assert_not_called() + +def test_poll_period_comes_from_the_getter(): + async def do_update(): + return 1 + + # A bare getter is read once, when the controller connects. + attr = AttrR(Int(), getter=do_update) + assert attr.poll_period == ONCE + + # Wrapping it in Polled schedules it instead. + attr_explicit = AttrR(Int(), getter=Polled(do_update, period=0.5)) + assert attr_explicit.poll_period == 0.5 + + # NotPolled is never scheduled - on-demand poll() only. + attr_on_demand = AttrR(Int(), getter=NotPolled(do_update)) + assert attr_on_demand.poll_period is None + assert attr_on_demand.has_getter() + + attr_no_getter = AttrR(Int()) + assert attr_no_getter.poll_period is None @pytest.mark.asyncio @@ -84,7 +165,7 @@ async def test_wait_for_predicate(mocker: MockerFixture): async def update(attr: AttrR): while True: await asyncio.sleep(0.1) - await attr.update(attr.get() + 3) # 3, 6, 9, 12 != 10 + await attr.update(attr.readback + 3) # 3, 6, 9, 12 != 10 asyncio.create_task(update(attr)) @@ -93,7 +174,7 @@ def predicate(v: int) -> bool: return v > 10 wait_mock = mocker.spy(asyncio, "wait_for") - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError, match="Timeout waiting 0.2s for .* predicate"): await attr.wait_for_predicate(predicate, timeout=0.2) await attr.wait_for_predicate(predicate, timeout=1) @@ -116,7 +197,7 @@ async def update(attr: AttrR): asyncio.create_task(update(attr)) wait_mock = mocker.spy(asyncio, "wait_for") - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError, match="Timeout waiting 0.2s for .* value 10"): await attr.wait_for_value(10, timeout=0.2) await attr.wait_for_value(1, timeout=1) @@ -131,20 +212,18 @@ async def update(attr: AttrR): @pytest.mark.asyncio async def test_attributes(): device = {"state": "Idle", "number": 1, "count": False} - ui = {"state": "", "number": 0, "count": False, "update_count": 0} + ui = {"state": "", "number": 0, "update_count": 0} async def update_ui(value, key): ui[key] = value ui["update_count"] += 1 - async def send(_attr, value, key): + async def send(value, key): device[key] = value - - async def device_add(): - device["number"] += 1 + return value # accepted value echoes straight back to the readback attr_r = AttrR(String()) - attr_r.add_on_update_callback(partial(update_ui, key="state"), always=False) + attr_r.add_readback_callback(partial(update_ui, key="state"), always=False) await attr_r.update(device["state"]) assert ui["state"] == "Idle" # Update with new value triggers callback @@ -153,50 +232,83 @@ async def device_add(): # Identical update does not trigger callback as always=False assert ui["update_count"] == 1 - attr_rw = AttrRW(Int()) - attr_rw._on_put_callback = partial(send, key="number") - attr_rw.add_sync_setpoint_callback(partial(update_ui, key="number")) - await attr_rw.put(2, sync_setpoint=True) + attr_rw = AttrRW(Int(), setter=partial(send, key="number")) + attr_rw.add_readback_callback(partial(update_ui, key="number")) + await attr_rw.set(2) assert device["number"] == 2 assert ui["number"] == 2 @pytest.mark.asyncio -async def test_attribute_io(): - @dataclass - class MyAttributeIORef(AttributeIORef): - cool: int +async def test_soft_attribute_self_wires(): + """With no getter/setter, AttrRW.set() pushes straight to readback.""" + attr = AttrRW(Int()) + assert not attr.has_getter() + assert not attr.has_setter() + + await attr.set(40) + assert attr.setpoint == 40 + assert attr.readback == 40 + - class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - async def update(self, attr: AttrR[int, MyAttributeIORef]): - print("I am updating", self.ref_type, attr.io_ref.cool) +@pytest.mark.asyncio +async def test_setter_return_value_updates_readback(): + accepted = {} - class MyController(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) + async def setter(value): + accepted["value"] = value + return value + 1 # device clamps/accepts a different value - def __init__(self): - super().__init__(ios=[MyAttributeIO()]) + attr = AttrRW(Int(), setter=setter) - c = MyController() + await attr.set(10) + assert accepted["value"] == 10 + assert attr.setpoint == 11 + assert attr.readback == 11 - class ControllerNoIO(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - @dataclass - class OtherAttributeIORef(AttributeIORef): - not_cool: int +@pytest.mark.asyncio +async def test_setter_with_no_return_leaves_readback_untouched(): + async def setter(value): + return None - class MissingIOController(Controller): - my_attr = AttrR(Int(), io_ref=OtherAttributeIORef(not_cool=5)) + attr = AttrRW(Int(), setter=setter) - with pytest.raises(ValueError, match="does not have an AttributeIO to handle"): - controller = MissingIOController() - controller._connect_attribute_ios() + await attr.set(5) + assert attr.setpoint == 5 + assert attr.readback == 0 # unchanged - no getter/poll has happened - await c.initialise() - c._connect_attribute_ios() - await c.my_attr.bind_update_callback()() + +@pytest.mark.asyncio +async def test_attrw_setter_return_value_updates_setpoint_cache(): + async def setter(value): + return value + 1 + + attr = AttrW(Int(), setter=setter) + + await attr.set(5) + assert attr.setpoint == 6 + + +@pytest.mark.asyncio +async def test_set_setter_exception_is_caught_and_logged(mocker: MockerFixture): + async def do_set(value): + raise ValueError("do_set failed") + + attr = AttrW(Int(), setter=do_set) + mock_logger = mocker.patch("fastcs.attributes.attr_w.logger") + + # exception is caught, not raised, and the setpoint is still cached + await attr.set(5) + assert attr.setpoint == 5 + + # the setter's exception is the one logged, at error level + logged_exception = mock_logger.opt.call_args.kwargs["exception"] + assert isinstance(logged_exception, ValueError) + assert str(logged_exception) == "do_set failed" + mock_logger.opt.return_value.error.assert_called_once_with( + "Set failed", attribute=attr, setpoint=5 + ) class DummyConnection: @@ -264,38 +376,8 @@ async def set(self, uri: str, value: float | int): self._float_value = value -NumberT = TypeVar("NumberT", int, float) - - @pytest.mark.asyncio() -async def test_dynamic_attribute_io_specification(): - @dataclass - class DemoParameterAttributeIORef(AttributeIORef, Generic[NumberT]): - name: str - subsystem: str - connection: DummyConnection - - @property - def uri(self): - return f"{self.subsystem}/{self.name}" - - class DemoParameterAttributeIO(AttributeIO[NumberT, DemoParameterAttributeIORef]): - async def update( - self, - attr: AttrR[NumberT, DemoParameterAttributeIORef], - ): - value = await attr.io_ref.connection.get(attr.io_ref.uri) - await attr.update(value) # type: ignore - - async def send( - self, - attr: AttrW[NumberT, DemoParameterAttributeIORef], - value: NumberT, - ) -> None: - await attr.io_ref.connection.set(attr.io_ref.uri, value) - if isinstance(attr, AttrRW): - await self.update(attr) - +async def test_dynamic_attribute_getter_setter_specification(): class DemoParameterController(Controller): ro_int_parameter: AttrR int_parameter: AttrRW @@ -312,22 +394,36 @@ async def initialise(self): for parameter_response in example_introspection_response: try: ro = parameter_response["read_only"] - ref = DemoParameterAttributeIORef( - name=parameter_response["name"], - subsystem=parameter_response["subsystem"], - connection=self._connection, - ) - attr_class = AttrR if ro else AttrRW - attr = attr_class( - datatype=dtype_mapping[parameter_response["dtype"]]( - min=parameter_response.get("min", None), - max=parameter_response.get("max", None), - ), - io_ref=ref, - initial_value=parameter_response.get("value", None), + name = parameter_response["name"] + uri = f"{parameter_response['subsystem']}/{name}" + datatype = dtype_mapping[parameter_response["dtype"]]( + min=parameter_response.get("min", None), + max=parameter_response.get("max", None), ) - self.add_attribute(ref.name, attr) + async def getter(uri=uri) -> int | float: + return await self._connection.get(uri) # type: ignore[return-value] + + if ro: + attr = AttrR( + datatype, + getter=getter, + initial_value=parameter_response.get("value", None), + ) + else: + + async def setter(value, uri=uri): + await self._connection.set(uri, value) + return value + + attr = AttrRW( + datatype, + getter=getter, + setter=setter, + initial_value=parameter_response.get("value", None), + ) + + self.add_attribute(name, attr) except Exception as e: print( "Exception constructing attribute from parameter response:", @@ -335,130 +431,11 @@ async def initialise(self): e, ) - c = DemoParameterController(ios=[DemoParameterAttributeIO()]) + c = DemoParameterController() await c.initialise() - c._connect_attribute_ios() - await c.ro_int_parameter.bind_update_callback()() - assert c.ro_int_parameter.get() == 10 - await c.ro_int_parameter.bind_update_callback()() - assert c.ro_int_parameter.get() == 11 - - await c.int_parameter.put(20) - assert c.int_parameter.get() == 20 - - -@pytest.mark.asyncio -async def test_attribute_no_io(mocker: MockerFixture): - class MyController(Controller): - no_ref = AttrRW(Int()) - base_class_ref = AttrRW(Int(), io_ref=AttributeIORef()) - - with pytest.raises( - ValueError, - match="MyController does not have an AttributeIO to handle AttributeIORef", - ): - c = MyController() - c._connect_attribute_ios() - - class SimpleAttributeIO(AttributeIO[int]): - async def update(self, attr): - await attr.update(100) - - with pytest.raises( - RuntimeError, match="More than one AttributeIO class handles AttributeIORef" - ): - MyController(ios=[SimpleAttributeIO(), SimpleAttributeIO()]) - - # we need to explicitly pass an AttributeIO if we want to handle instances of - # the AttributeIORef base class - c = MyController(ios=[SimpleAttributeIO()]) - assert not c.no_ref.has_io_ref() - assert c.base_class_ref.has_io_ref() - - await c.initialise() - c._connect_attribute_ios() - - # There is a difference between providing an AttributeIO for the default - # AttributeIORef class and not specifying the io_ref for an Attribute - # default callbacks are not provided by AttributeIO subclasses - - sync_setpoint_mock = mocker.AsyncMock() - c.no_ref.add_sync_setpoint_callback(sync_setpoint_mock) - - await c.no_ref.put(40) - sync_setpoint_mock.assert_called_once_with(40) # sync setpoint called on first set - sync_setpoint_mock.reset_mock() - await c.no_ref.put(41) # sync setpoint callback not called without flag - await c.no_ref.put(42, sync_setpoint=True) - sync_setpoint_mock.assert_called_once_with(42) - - c2 = MyController(ios=[SimpleAttributeIO()]) - - await c2.initialise() - c2._connect_attribute_ios() - - assert c2.base_class_ref.get() == 0 - await c2.base_class_ref.bind_update_callback()() - assert c2.base_class_ref.get() == 100 - - -def test_add_update_callback_twice_raises(): - async def do_update(attr: AttrR[int]): - pass - - attr = AttrRW(Int()) - attr.set_update_callback(do_update) - - with pytest.raises(RuntimeError): - attr.set_update_callback(do_update) - - -@pytest.mark.asyncio -async def test_bind_update(): - attr = AttrRW(Int()) - - with pytest.raises(RuntimeError): - attr.bind_update_callback() - - async def do_update(attr: AttrR[int]): - await attr.update(5) - - attr.set_update_callback(do_update) - callback = attr.bind_update_callback() - - await callback() - assert attr.get() == 5 - - -@pytest.mark.asyncio -async def test_bind_update_exception(): - attr = AttrRW(Int()) - - async def do_update(attr: AttrR[int]): - raise ValueError("do_update failed") - - attr.set_update_callback(do_update) - - callback = attr.bind_update_callback() - - with pytest.raises(ValueError): - await callback() - - -@pytest.mark.asyncio -async def test_put(): - attr = AttrW(Int()) - - async def do_put(attr: AttrW[int], value: int): - raise ValueError("do_put failed") - - async def do_sync_setpoint(setpoint: int): - raise ValueError("do_sync_setpoint failed") - - attr.set_on_put_callback(do_put) - attr.add_sync_setpoint_callback(do_sync_setpoint) - await attr.put(5) + assert await c.ro_int_parameter.poll() == 10 + assert await c.ro_int_parameter.poll() == 11 - with pytest.raises(RuntimeError): - attr.set_on_put_callback(do_put) + await c.int_parameter.set(20) + assert c.int_parameter.readback == 20 diff --git a/tests/test_control_system.py b/tests/test_control_system.py index ca151cc02..66b78b192 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -1,9 +1,8 @@ import asyncio -from dataclasses import dataclass import pytest -from fastcs.attributes import AttributeIO, AttributeIORef, AttrR +from fastcs.attributes import AttrR, NotPolled, Polled from fastcs.control_system import FastCS from fastcs.controllers import Controller from fastcs.datatypes import Int @@ -59,40 +58,42 @@ async def do_nothing_static(self): @pytest.mark.asyncio async def test_update_periods(): - @dataclass - class AttributeIORefTimesCalled(AttributeIORef): - update_period: float | None = None - _times_called = 0 + times_called = {"once": 0, "quickly": 0, "never": 0} - class AttributeIOTimesCalled(AttributeIO[int, AttributeIORefTimesCalled]): - async def update(self, attr: AttrR[int, AttributeIORefTimesCalled]): - attr.io_ref._times_called += 1 - await attr.update(attr.io_ref._times_called) + async def get_once(): + times_called["once"] += 1 + return times_called["once"] + + async def get_quickly(): + times_called["quickly"] += 1 + return times_called["quickly"] + + async def get_never(): + times_called["never"] += 1 + return times_called["never"] class MyController(Controller): - update_once = AttrR(Int(), io_ref=AttributeIORefTimesCalled(update_period=ONCE)) - update_quickly = AttrR( - Int(), io_ref=AttributeIORefTimesCalled(update_period=0.1) - ) - update_never = AttrR( - Int(), io_ref=AttributeIORefTimesCalled(update_period=None) - ) - - controller = MyController(ios=[AttributeIOTimesCalled()]) + def __init__(self): + super().__init__() + self.update_once = AttrR(Int(), getter=Polled(get_once, period=ONCE)) + self.update_quickly = AttrR(Int(), getter=Polled(get_quickly, period=0.1)) + self.update_never = AttrR(Int(), getter=NotPolled(get_never)) + + controller = MyController() loop = asyncio.get_event_loop() fastcs = FastCS(controller, [], loop) - assert controller.update_quickly.get() == 0 - assert controller.update_once.get() == 0 - assert controller.update_never.get() == 0 + assert controller.update_quickly.readback == 0 + assert controller.update_once.readback == 0 + assert controller.update_never.readback == 0 asyncio.create_task(fastcs.serve(interactive=False)) await asyncio.sleep(0.5) - assert controller.update_quickly.get() > 1 - assert controller.update_once.get() == 1 - assert controller.update_never.get() == 0 + assert controller.update_quickly.readback > 1 + assert controller.update_once.readback == 1 + assert controller.update_never.readback == 0 assert len(fastcs._scan_tasks) == 1 assert len(fastcs._initial_coros) == 1 diff --git a/tests/test_multi_controller.py b/tests/test_multi_controller.py index 9e27f62e5..bde9b2f38 100644 --- a/tests/test_multi_controller.py +++ b/tests/test_multi_controller.py @@ -166,7 +166,7 @@ async def test_pva_transport_serves_two_controllers_with_distinct_pvi_roots(): transport = EpicsPVATransport() transport.connect([api1, api2], asyncio.get_event_loop()) - providers = await transport._ioc._build_providers() + providers = transport._ioc._providers pv_names = {name for provider in providers for name in provider.keys()} assert "ALPHA:PVI" in pv_names diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index 8b7c12205..ece949116 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -8,7 +8,6 @@ from softioc import softioc from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) from tests.util import ColourEnum @@ -54,7 +53,7 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): record = make_record.return_value attribute = AttrR(Int()) - attribute.add_on_update_callback = mocker.MagicMock() + attribute.add_readback_callback = mocker.MagicMock() _create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute) @@ -62,8 +61,8 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") # Extract the callback generated and set in the function and call it - attribute.add_on_update_callback.assert_called_once_with(mocker.ANY) - record_set_callback = attribute.add_on_update_callback.call_args[0][0] + attribute.add_readback_callback.assert_called_once_with(mocker.ANY) + record_set_callback = attribute.add_readback_callback.call_args[0][0] await record_set_callback(1) record.set.assert_called_once_with(1) @@ -228,27 +227,32 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): ) record = make_record.return_value - attribute = AttrW(Int()) - attribute.put = mocker.AsyncMock() - attribute.add_sync_setpoint_callback = mocker.MagicMock() + attribute = AttrRW(Int()) + attribute.set = mocker.AsyncMock() + attribute.add_setpoint_callback = mocker.MagicMock() _create_and_link_write_pv("PREFIX", "PV", "attr", None, attribute) make_record.assert_called_once_with("PREFIX:PV", attribute, on_update=mocker.ANY) add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "w") - # Extract the write update callback generated and set in the function and call it - attribute.add_sync_setpoint_callback.assert_called_once_with(mocker.ANY) - sync_setpoint_callback = attribute.add_sync_setpoint_callback.call_args[0][0] - await sync_setpoint_callback(1) + # Extract the setpoint callback generated and set in the function + attribute.add_setpoint_callback.assert_called_once_with(mocker.ANY) + set_setpoint_callback = attribute.add_setpoint_callback.call_args[0][0] + await set_setpoint_callback(1) record.set.assert_called_once_with(1, process=False) + # Unlike the old one-shot seeding, every setpoint change is mirrored. + record.set.reset_mock() + await set_setpoint_callback(2) + record.set.assert_called_once_with(2, process=False) + # Extract the on update callback generated and set in the function and call it on_update_callback = make_record.call_args[1]["on_update"] await on_update_callback(1) - attribute.put.assert_called_once_with(1) + attribute.set.assert_called_once_with(1) class LongEnum(enum.Enum): @@ -343,11 +347,11 @@ def test_get_output_record_raises(mocker: MockerFixture): class EpicsController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) + read_int = AttrR(Int()) + read_write_int = AttrRW(Int()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) + write_bool = AttrW(Bool()) read_string = AttrRW(String()) enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) one_d_waveform = AttrRW(Waveform(np.int32, (10,))) diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index 7544f5308..fd1b1ddd0 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -16,6 +16,7 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): assert parent_pvi["value"] == { "a": {"r": f"{pv_prefix}:A"}, "b": {"r": f"{pv_prefix}:B_RBV", "w": f"{pv_prefix}:B"}, + "clamped": {"r": f"{pv_prefix}:Clamped_RBV", "w": f"{pv_prefix}:Clamped"}, "childvector": {"d": f"{pv_prefix}:ChildVector:PVI"}, } @@ -45,6 +46,10 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): "d": {"x": f"{pv_prefix}:ChildVector:0:D"}, } + initial_value = ctxt.get(f"{pv_prefix}:Clamped_RBV") + assert initial_value # Clamped initial value is truthy + assert ctxt.get(f"{pv_prefix}:Clamped") == initial_value # Setpoint is synced + # Assert alias. Aliases do not show up in PVI structure assert ctxt.get(f"{pv_prefix}:B") == ctxt.get(f"{pv_prefix}:AliasB") == 0 ctxt.put(f"{pv_prefix}:B", 10, wait=True) diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 947ade79d..098c3cfc6 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -12,6 +12,7 @@ from p4p.client.asyncio import Context from p4p.client.thread import Context as ThreadContext from p4p.nt import NTTable +from pytest_mock import MockerFixture from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector @@ -62,6 +63,7 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): assert child_pvi["display"] == {"description": "some sub controller"} assert child_pvi["value"] == { "c": {"w": f"{pv_prefix}:Child:1:C"}, + "clamped": {"rw": f"{pv_prefix}:Child:1:Clamped"}, "d": {"x": f"{pv_prefix}:Child:1:D"}, "e": {"r": f"{pv_prefix}:Child:1:E"}, "f": {"rw": f"{pv_prefix}:Child:1:F"}, @@ -71,6 +73,12 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): "j": {"r": f"{pv_prefix}:Child:1:J"}, } + initial_value = await ctxt.get(f"{pv_prefix}:Child:1:Clamped_RBV") + assert initial_value # Clamped initial value is truthy + assert ( + await ctxt.get(f"{pv_prefix}:Child:1:Clamped") == initial_value + ) # Setpoint is synced + @pytest.mark.asyncio async def test_scan_method(p4p_subprocess: tuple[str, Queue]): @@ -654,3 +662,55 @@ async def put_pvs(): assert ( pytest.approx((end - start).total_seconds(), abs=0.1) == expected_duration ) + + +@pytest.mark.asyncio +async def test_setpoint_seeded_by_initial_poll_reaches_transport( + mocker: MockerFixture, +): + """The PVs must exist by the end of ``connect()``, not ``serve()``. + + An ``AttrRW`` seeds its setpoint from its first readback (ADR 0020), and that + readback comes from the initial poll - which ``FastCS.serve`` runs *before* it + gathers the transports' ``serve()`` coroutines. A PV built in ``serve()`` would + miss the seed and keep serving the datatype default, so the setpoint PV read + ``0`` while ``attribute.setpoint`` read the seeded value. + """ + + class SeedController(Controller): + def __init__(self): + super().__init__() + self.a = AttrRW(Int(), getter=self.get_a) + + async def get_a(self) -> int: + return 10 + + controller = SeedController() + controller.set_path([str(uuid4())]) + await controller.initialise() + controller.post_initialise() + controller_api, _, initial_coros = controller.create_api_and_tasks() + + attribute = controller_api.attributes["a"] + assert isinstance(attribute, AttrRW) + published: list[int] = [] + register_callback = attribute.add_setpoint_callback + + def record_setpoints(callback): + async def wrapper(value): + published.append(value) + await callback(value) + + register_callback(wrapper) + + mocker.patch.object(attribute, "add_setpoint_callback", record_setpoints) + + transport = EpicsPVATransport() + transport.connect(controller_apis=[controller_api], loop=asyncio.get_running_loop()) + + # Nothing has awaited transport.serve() at this point - as in FastCS.serve() + for coro in initial_coros: + await coro() + + assert attribute.setpoint == 10 + assert published == [10] diff --git a/tests/transports/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py index 46d2fc8a9..193d081cd 100644 --- a/tests/transports/graphQL/test_graphql.py +++ b/tests/transports/graphQL/test_graphql.py @@ -8,7 +8,6 @@ from pytest_mock import MockerFixture from tests.assertable_controller import ( AssertableControllerAPI, - MyTestAttributeIORef, MyTestController, ) @@ -18,11 +17,11 @@ class GraphQLController(MyTestController): - read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) - read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) + read_int = AttrR(Int()) + read_write_int = AttrRW(Int()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) + write_bool = AttrW(Bool()) read_string = AttrRW(String()) diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py index 80af6698b..2f458cb05 100644 --- a/tests/transports/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -98,8 +98,8 @@ def test_enum( enum_attr = rest_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) enum_cls = enum_attr.datatype.dtype - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(0) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(0) expect = 0 with rest_controller_api.assert_read_here(["enum"]): response = test_client.get("/enum") @@ -109,8 +109,8 @@ def test_enum( with rest_controller_api.assert_write_here(["enum"]): response = test_client.put("/enum", json={"value": new}) assert test_client.get("/enum").json()["value"] == new - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(2) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(2) def test_1d_waveform( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient @@ -118,8 +118,8 @@ def test_1d_waveform( attribute = rest_controller_api.attributes["one_d_waveform"] expect = np.zeros((10,), dtype=np.int32) assert isinstance(attribute, AttrRW) - assert np.array_equal(attribute.get(), expect) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, expect) + assert isinstance(attribute.readback, np.ndarray) with rest_controller_api.assert_read_here(["one_d_waveform"]): response = test_client.get("one-d-waveform") @@ -131,8 +131,8 @@ def test_1d_waveform( result = test_client.get("/one-d-waveform") assert np.array_equal(result.json()["value"], new) - assert np.array_equal(attribute.get(), new) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, new) + assert isinstance(attribute.readback, np.ndarray) def test_2d_waveform( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient @@ -140,8 +140,8 @@ def test_2d_waveform( attribute = rest_controller_api.attributes["two_d_waveform"] assert isinstance(attribute, AttrRW) expect = np.zeros((10, 10), dtype=np.int32) - assert np.array_equal(attribute.get(), expect) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, expect) + assert isinstance(attribute.readback, np.ndarray) with rest_controller_api.assert_read_here(["two_d_waveform"]): result = test_client.get("/two-d-waveform") @@ -152,8 +152,8 @@ def test_2d_waveform( result = test_client.get("/two-d-waveform") assert np.array_equal(result.json()["value"], new) - assert np.array_equal(attribute.get(), new) - assert isinstance(attribute.get(), np.ndarray) + assert np.array_equal(attribute.readback, new) + assert isinstance(attribute.readback, np.ndarray) def test_go( self, rest_controller_api: AssertableControllerAPI, test_client: TestClient diff --git a/tests/transports/tango/test_dsr.py b/tests/transports/tango/test_dsr.py index 61a8f73ef..1eef2c242 100644 --- a/tests/transports/tango/test_dsr.py +++ b/tests/transports/tango/test_dsr.py @@ -149,8 +149,8 @@ def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context enum_attr = tango_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) enum_cls = enum_attr.datatype.dtype - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(0) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(0) expect = 0 with tango_controller_api.assert_read_here(["enum"]): result = tango_context.read_attribute("Enum").value @@ -159,8 +159,8 @@ def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context with tango_controller_api.assert_write_here(["enum"]): tango_context.write_attribute("Enum", new) assert tango_context.read_attribute("Enum").value == new - assert isinstance(enum_attr.get(), enum_cls) - assert enum_attr.get() == enum_cls(1) + assert isinstance(enum_attr.readback, enum_cls) + assert enum_attr.readback == enum_cls(1) def test_1d_waveform( self, tango_controller_api: AssertableControllerAPI, tango_context From 4d89906d413183ac0f4fe85dffa3a6b5f021ad07 Mon Sep 17 00:00:00 2001 From: "Tom C (DLS)" <101418278+coretl@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:11:29 +0100 Subject: [PATCH 33/36] =?UTF-8?q?methods:=20typed=20commands=20=E2=80=94?= =?UTF-8?q?=20positional=20arguments=20and=20a=20return=20value=20(#419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * methods(#393): typed commands - positional arguments and a return value Lifts the void/void restriction on `Command`. A `@command` may now take positional arguments and return a value, both of known types, and the captured `inspect.Signature` becomes the public description transports read. - `Command[P, T]` is generic over its parameters and return type; `__call__` and `bind` forward arguments. `argument_types`, `return_datatype` and `is_void` are the shortcuts a transport needs. - Arguments and returns are validated independently at construction: positional only, fully annotated, and one of bool/int/float/str/Enum. Keyword-only arguments are rejected, pending the spike in #403. - `Method` no longer forbids arguments or a return type; `Scan` keeps that restriction for itself. - Transport capability is declared, not assumed. REST and GraphQL round-trip a typed call; Tango carries at most one argument and no enum; EPICS CA and PVA are void-only. Anything a transport cannot serve is skipped with a warning at start-up rather than failing the whole controller, and the EPICS GUI now honours `enabled` so it does not draw a control for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * docs(#393): ignore the unresolvable ParamSpec target in nitpicky mode `Command[P, T]` renders a reference to a bare `P`, which has no target of its own - the same problem the existing TypeVar entries in nitpick_ignore cover. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * test(#393): cover the per-transport typed-command skips The PVA provider, the Tango command collection and the EPICS GUI all now have a branch for a command they cannot serve, and none of them were exercised. The PVA provider builds without a server as long as there is a running loop, and the Tango collection without a device server, so both are reachable here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * refactor(#393): add error context by reraising, not by passing strings Addresses review on #419. - `_validate_datatype` loses its `what` parameter and gains a docstring. It now describes only the annotation it rejected; `_validate_arguments` and `_validate_return` catch that and reraise naming the argument or return value it came from. - `Method._validate_takes_no_arguments`/`_validate_returns_nothing` lose their `kind` parameter the same way, with `Scan`/`UnboundScan` catching and reraising to name the method kind. The resulting error messages are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LE4eLtgCbdRjrg7t17HRPa * refactor(#393): drop the skip parameter from _validate_arguments The helper took a `skip` count so it could be shared between `Command` (no leading parameter) and `UnboundCommand` (a leading `self`). Both call sites know which of their parameters are command arguments, so they now pass the parameters themselves and the helper takes a plain sequence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019hVTucKg9bsV4LmSGdumAd * chore: remove generated opis --------- Co-authored-by: Claude Co-authored-by: claude Co-authored-by: Shihab Suliman --- docs/conf.py | 3 + docs/explanations/transports.md | 7 + docs/how-to/typed-commands.md | 101 ++++++++++ src/fastcs/methods/__init__.py | 1 + src/fastcs/methods/command.py | 182 +++++++++++++++--- src/fastcs/methods/method.py | 54 ++++-- src/fastcs/methods/scan.py | 15 +- src/fastcs/transports/epics/ca/ioc.py | 14 ++ src/fastcs/transports/epics/gui.py | 8 + src/fastcs/transports/epics/pva/ioc.py | 13 ++ src/fastcs/transports/graphql/graphql.py | 34 +++- src/fastcs/transports/rest/rest.py | 68 ++++++- src/fastcs/transports/tango/dsr.py | 58 +++++- tests/test_methods.py | 113 ++++++++++- tests/test_typed_commands.py | 227 +++++++++++++++++++++++ 15 files changed, 832 insertions(+), 66 deletions(-) create mode 100644 docs/how-to/typed-commands.md create mode 100644 tests/test_typed_commands.py diff --git a/docs/conf.py b/docs/conf.py index edfd712dd..0325064f3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -104,6 +104,9 @@ ("docutils", "fastcs.demo.temperature_attr.TemperatureControllerSettings"), # TypeVar without docstrings still give warnings ("py:class", "strawberry.schema.schema.Schema"), + # A ParamSpec gets no target of its own, so `Command[P, T]` renders a + # reference to a bare `P` that resolves to nothing + ("py:class", "P"), ] nitpick_ignore_regex = [ ("py:class", r"fastcs.*.DType_T"), diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md index 5a99c9ff5..e722f891d 100644 --- a/docs/explanations/transports.md +++ b/docs/explanations/transports.md @@ -11,6 +11,13 @@ A transport connects a `ControllerAPI` to an external protocol. The `ControllerA - Scan methods (`@scan`) - Sub-controller APIs (hierarchical structure) +A command may take arguments and return a value, and not every protocol can +carry such a call. A transport reads `command.signature` (or the +`argument_types`/`return_datatype`/`is_void` shortcuts) and decides for itself: +serve it, or set `command.enabled = False` and log a warning saying why, so the +rest of the controller is still served. See +[](../how-to/typed-commands.md) for what each transport does. + ## Implementing a Transport Subclass `Transport` and implement `connect()` and `serve()`: diff --git a/docs/how-to/typed-commands.md b/docs/how-to/typed-commands.md new file mode 100644 index 000000000..b85f34655 --- /dev/null +++ b/docs/how-to/typed-commands.md @@ -0,0 +1,101 @@ +# Give a Command Arguments and a Return Value + +A `@command` may take positional arguments and give a value back. Both are +declared the ordinary way - by annotating the method - and both are optional and +independent, so a command can take arguments and return nothing, return +something and take nothing, or do both. + +```python +from fastcs.controllers import Controller +from fastcs.methods import command + +class Stage(Controller): + @command() + async def stop(self) -> None: + """Void: no arguments, no return value.""" + await self._protocol.stop() + + @command() + async def move_to(self, position: float, wait: bool) -> None: + """Two positional arguments.""" + await self._protocol.move(position, wait) + + @command() + async def measure(self) -> float: + """A return value.""" + return await self._protocol.read_position() +``` + +## What a command may take and return + +Arguments and return values are `bool`, `int`, `float`, `str`, or an +`enum.Enum` subclass - the same python types an attribute holds, minus arrays +and tables. Everything must be annotated: a command's signature is what +transports read to decide how to expose it, so it has to be fully known. + +```python +@command() +async def move_to(self, position): # TypeError: no type annotation + ... + +@command() +async def plot(self, trace: list[float]): # TypeError: unsupported type + ... +``` + +Arguments are positional. Keyword-only arguments, `*args` and `**kwargs` are +rejected. + +An array-valued command has an attribute-shaped alternative: write the array to +an `AttrW` and trigger a void command, rather than passing it as an argument. + +## Which transports serve them + +Not every protocol can carry a typed call, so each transport declares what it +can do rather than the framework assuming they are all alike. A command a +transport cannot serve is **skipped with a warning at start-up** - the rest of +the controller is still served. + +| Transport | Void command | Arguments | Return value | +| --------- | ------------ | --------- | ------------ | +| REST | ✅ | ✅ any number, as a JSON body | ✅ as `{"value": …}` | +| GraphQL | ✅ | ✅ any number, as mutation arguments | ✅ the mutation result | +| Tango | ✅ | ⚠️ at most one, and not an enum | ⚠️ not an enum | +| EPICS CA | ✅ | ❌ | ❌ | +| EPICS PVA | ✅ | ❌ | ❌ | + +The EPICS transports serve a command as a single "do it" PV. There is no PV +representation of "call with these arguments and give me this back" that is not +already a set of attributes, so a typed command has nothing to map onto. Tango +commands carry at most one input value, which is a limit of the protocol. + +If a command must be reachable over EPICS, keep it void and put its arguments +and results on attributes: + +```python +class Stage(Controller): + target = AttrRW(float) + last_position = AttrR(float) + + @command() + async def move(self) -> None: + await self._protocol.move(self.target.setpoint) + await self.last_position.update(await self._protocol.read_position()) +``` + +## Reading a command's signature + +A transport - or anything else walking a `ControllerAPI` - gets the whole +picture from the command itself: + +```python +command = controller_api.command_methods["move_to"] + +command.signature # (position: float, wait: bool) -> None +command.argument_types # (float, bool) +command.return_datatype # None +command.is_void # False +``` + +`signature` is the bound signature, without `self`, so it is what a caller +would actually pass. diff --git a/src/fastcs/methods/__init__.py b/src/fastcs/methods/__init__.py index 0cdeb616a..2136bbc80 100644 --- a/src/fastcs/methods/__init__.py +++ b/src/fastcs/methods/__init__.py @@ -1,3 +1,4 @@ +from .command import COMMAND_DTYPES as COMMAND_DTYPES from .command import Command as Command from .command import CommandCallback as CommandCallback from .command import UnboundCommand as UnboundCommand diff --git a/src/fastcs/methods/command.py b/src/fastcs/methods/command.py index 6818d7137..811e37238 100644 --- a/src/fastcs/methods/command.py +++ b/src/fastcs/methods/command.py @@ -1,7 +1,10 @@ -from collections.abc import Callable, Coroutine +import enum +from collections.abc import Callable, Coroutine, Sequence +from inspect import Parameter, Signature from types import MethodType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar +from fastcs.datatypes import DType from fastcs.logging import logger from fastcs.methods.method import Controller_T, Method @@ -9,37 +12,161 @@ from fastcs.controllers import BaseController # noqa: F401 -UnboundCommandCallback = Callable[[Controller_T], Coroutine[None, None, None]] +P = ParamSpec("P") +"""The parameters a `Command` takes""" +T = TypeVar("T") +"""The value a `Command` returns""" + +UnboundCommandCallback = Callable[ + Concatenate[Controller_T, P], Coroutine[None, None, T] +] """A Command callback that is unbound and must be called with a `Controller` instance""" -CommandCallback = Callable[[], Coroutine[None, None, None]] +CommandCallback = Callable[P, Coroutine[None, None, T]] """A Command callback that is bound and can be called without `self`""" -class Command(Method["BaseController"]): +COMMAND_DTYPES: tuple[type, ...] = (bool, int, float, str, enum.Enum) +"""The types a command argument or return value may have. + +A subset of ``DType``: arrays and tables are deliberately left out. Serving them +would mean duplicating the array serialisation each transport already has for +attributes rather than sharing it, which ADR 0015 explicitly does not want, and +an array-valued command has an attribute-shaped alternative today. +""" + + +def _validate_datatype(annotation: Any) -> type[DType]: + """Check that an annotation is a type a command can take or return. + + Args: + annotation: The annotation of a command parameter or return value + + Returns: + The annotation, once it is known to be a type a command can carry + + Raises: + TypeError: If the annotation is missing, or is not one of + `COMMAND_DTYPES`. The message describes the annotation alone - + the caller catches it to say which argument or return value it + came from. + + """ + if annotation is Signature.empty: + raise TypeError( + "has no type annotation. A command's argument and return types " + "must be fully known" + ) + + if not (isinstance(annotation, type) and issubclass(annotation, COMMAND_DTYPES)): + raise TypeError( + f"has unsupported type {annotation!r}. Commands take and return " + f"{', '.join(t.__name__ for t in COMMAND_DTYPES)}" + ) + + return annotation + + +def _validate_arguments( + parameters: Sequence[Parameter], fn: Callable +) -> tuple[type[DType], ...]: + """Check a command's parameters and collect their types. + + Args: + parameters: The parameters that are the command's arguments. An unbound + method still declares ``self``, so its caller drops the leading one + fn: The wrapped function, to name it in errors + + Returns: + The type of each argument, in order + + Raises: + TypeError: If a parameter is not a positional argument of a known type + + """ + argument_types = [] + for parameter in parameters: + if parameter.kind is Parameter.KEYWORD_ONLY: + raise TypeError( + f"Command {fn.__qualname__} has keyword-only argument " + f"'{parameter.name}'. Command arguments are positional; " + "keyword arguments are not supported yet" + ) + if parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD): + raise TypeError( + f"Command {fn.__qualname__} takes *args or **kwargs. A " + "command's arguments must be fully known" + ) + + try: + argument_types.append(_validate_datatype(parameter.annotation)) + except TypeError as error: + raise TypeError( + f"Argument '{parameter.name}' of command {fn.__qualname__} {error}" + ) from error + + return tuple(argument_types) + + +def _validate_return(signature: Signature, fn: Callable) -> type[DType] | None: + annotation = signature.return_annotation + if annotation in (None, Signature.empty): + return None + + try: + return _validate_datatype(annotation) + except TypeError as error: + raise TypeError(f"Return value of command {fn.__qualname__} {error}") from error + + +class Command(Method["BaseController"], Generic[P, T]): """A `Controller` `Method` that performs a single action when called. + A command may take positional arguments and return a value, both of known + types - ``Command[[float], None]`` moves to a position, ``Command[[], None]`` + is the void case. What it takes and gives back is its ``signature``, which + is what a transport reads to decide how - or whether - to serve it. + This class contains a function that is bound to a specific `Controller` instance and is callable outside of the class context, without an explicit `self` parameter. Calling an instance of this class will call the bound `Controller` method. """ - def __init__(self, fn: CommandCallback, *, group: str | None = None): + def __init__(self, fn: CommandCallback[P, T], *, group: str | None = None): super().__init__(fn, group=group) - def _validate(self, fn: CommandCallback) -> None: + def _validate(self, fn: CommandCallback[P, T]) -> None: super()._validate(fn) - if not len(self.parameters) == 0: - raise TypeError(f"Command method cannot have arguments: {fn}") + self._argument_types = _validate_arguments(list(self.parameters.values()), fn) + self._return_datatype = _validate_return(self.signature, fn) + + @property + def argument_types(self) -> tuple[type[DType], ...]: + """The type of each positional argument the command takes.""" + return self._argument_types - async def __call__(self): - return await self.fn() + @property + def return_datatype(self) -> type[DType] | None: + """The type the command returns, or ``None`` if it returns nothing.""" + return self._return_datatype @property - def fn(self) -> CommandCallback: - async def command(): + def is_void(self) -> bool: + """Whether the command takes no arguments and returns nothing. + + A void command can be served by any transport; a typed one needs a + protocol that can carry a typed call. + """ + return not self._argument_types and self._return_datatype is None + + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: + return await self.fn(*args, **kwargs) + + @property + def fn(self) -> CommandCallback[P, T]: + async def command(*args: P.args, **kwargs: P.kwargs) -> T: try: - return await self._fn() + return await self._fn(*args, **kwargs) except Exception: logger.exception("Command failed", fn=self._fn) raise @@ -47,7 +174,7 @@ async def command(): return command -class UnboundCommand(Method[Controller_T]): +class UnboundCommand(Method[Controller_T], Generic[Controller_T, P, T]): """A wrapper of an unbound `Controller` method to be bound into a `Command`. This generic class stores an unbound `Controller` method - effectively a function @@ -59,24 +186,33 @@ class UnboundCommand(Method[Controller_T]): """ def __init__( - self, fn: UnboundCommandCallback[Controller_T], *, group: str | None = None + self, + fn: UnboundCommandCallback[Controller_T, P, T], + *, + group: str | None = None, ) -> None: super().__init__(fn, group=group) - def _validate(self, fn: UnboundCommandCallback[Controller_T]) -> None: + def _validate(self, fn: UnboundCommandCallback[Controller_T, P, T]) -> None: super()._validate(fn) - if not len(self.parameters) == 1: - raise TypeError("Command method cannot have arguments") + if not self.parameters: + raise TypeError(f"Command {fn.__qualname__} must be a method, taking self") + + # The leading parameter is the ``Controller`` this is bound to, not an + # argument of the command. + _validate_arguments(list(self.parameters.values())[1:], fn) + _validate_return(self.signature, fn) - def bind(self, controller: Controller_T) -> Command: + def bind(self, controller: Controller_T) -> Command[P, T]: return Command(MethodType(self.fn, controller), group=self.group) def command( *, group: str | None = None ) -> Callable[ - [UnboundCommandCallback[Controller_T]], UnboundCommandCallback[Controller_T] + [UnboundCommandCallback[Controller_T, P, T]], + UnboundCommandCallback[Controller_T, P, T], ]: """Decorator to register a `Controller` method as a `Command` @@ -88,8 +224,8 @@ def command( """ def wrapper( - fn: UnboundCommandCallback[Controller_T], - ) -> UnboundCommandCallback[Controller_T]: + fn: UnboundCommandCallback[Controller_T, P, T], + ) -> UnboundCommandCallback[Controller_T, P, T]: setattr(fn, "__unbound_command__", UnboundCommand(fn, group=group)) # noqa: B010 return fn diff --git a/src/fastcs/methods/method.py b/src/fastcs/methods/method.py index f475256e3..5843b12db 100644 --- a/src/fastcs/methods/method.py +++ b/src/fastcs/methods/method.py @@ -1,12 +1,12 @@ from asyncio import iscoroutinefunction from collections.abc import Callable, Coroutine from inspect import Signature, getdoc, signature -from typing import Generic +from typing import Any, Generic from fastcs.tracer import Tracer from fastcs.util import Controller_T -MethodCallback = Callable[..., Coroutine[None, None, None]] +MethodCallback = Callable[..., Coroutine[None, None, Any]] """Generic protocol for all `Controller` Method callbacks""" @@ -17,10 +17,7 @@ def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: super().__init__() self._docstring = getdoc(fn) - - sig = signature(fn, eval_str=True) - self._parameters = sig.parameters - self._return_type = sig.return_annotation + self._signature = signature(fn, eval_str=True) self._validate(fn) self._fn = fn @@ -28,19 +25,54 @@ def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: self.enabled = True def _validate(self, fn: MethodCallback) -> None: - if self.return_type not in (None, Signature.empty): - raise TypeError("Method return type must be None or empty") - if not iscoroutinefunction(fn): raise TypeError("Method must be async function") + def _validate_takes_no_arguments(self, expected: int) -> None: + """Reject a method that takes anything beyond its bound ``self``. + + Args: + expected: How many parameters a no-argument method has here - one + for an unbound method, which still declares ``self`` + + Raises: + TypeError: If the method takes arguments. The message describes + the fault alone - the caller catches it to say what kind of + method it was. + + """ + if len(self.parameters) != expected: + raise TypeError("method cannot have arguments") + + def _validate_returns_nothing(self) -> None: + """Reject a method that declares a return type. + + Raises: + TypeError: If the method returns something. The message describes + the fault alone - the caller catches it to say what kind of + method it was. + + """ + if self.return_type not in (None, Signature.empty): + raise TypeError("method return type must be None or empty") + + @property + def signature(self) -> Signature: + """The signature of the wrapped function. + + This is the public description of how to call the method, and what it + gives back - transports read it to decide how to expose the method, and + whether they can expose it at all. + """ + return self._signature + @property def return_type(self): - return self._return_type + return self._signature.return_annotation @property def parameters(self): - return self._parameters + return self._signature.parameters @property def docstring(self): diff --git a/src/fastcs/methods/scan.py b/src/fastcs/methods/scan.py index c995490a4..9630765e3 100644 --- a/src/fastcs/methods/scan.py +++ b/src/fastcs/methods/scan.py @@ -41,8 +41,11 @@ def period(self): def _validate(self, fn: ScanCallback) -> None: super()._validate(fn) - if not len(self.parameters) == 0: - raise TypeError("Scan method cannot have arguments") + try: + self._validate_takes_no_arguments(expected=0) + self._validate_returns_nothing() + except TypeError as error: + raise TypeError(f"Scan {error}") from error async def __call__(self): return await self._fn() @@ -82,8 +85,12 @@ def period(self): def _validate(self, fn: UnboundScanCallback[Controller_T]) -> None: super()._validate(fn) - if not len(self.parameters) == 1: - raise TypeError("Scan method cannot have arguments") + # The leading parameter is the ``Controller`` this is bound to. + try: + self._validate_takes_no_arguments(expected=1) + self._validate_returns_nothing() + except TypeError as error: + raise TypeError(f"Scan {error}") from error def bind(self, controller: Controller_T) -> Scan: return Scan(MethodType(self.fn, controller), self._period) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 29a89d3a2..4be9245a0 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -257,6 +257,20 @@ def _create_and_link_command_pvs( pv_prefix = pv_prefix_from_path(controller_api.path) for attr_name, method in controller_api.command_methods.items(): + if not method.is_void: + # A PV is a value, not a call: there is no representation of + # "call with these arguments, get this back" that is not already + # a set of attributes. Skip rather than refuse to serve the + # controller at all (ADR 0015). + logger.warning( + "EPICS CA transport cannot serve a command that takes " + "arguments or returns a value", + command=attr_name, + signature=str(method.signature), + ) + method.enabled = False + continue + pv_name = snake_to_pascal(attr_name) alias = aliases.get(f"{pv_prefix}:{pv_name}", None) diff --git a/src/fastcs/transports/epics/gui.py b/src/fastcs/transports/epics/gui.py index 882a83a02..7b4b45896 100644 --- a/src/fastcs/transports/epics/gui.py +++ b/src/fastcs/transports/epics/gui.py @@ -163,6 +163,11 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: groups: dict[str, list[ComponentUnion]] = {} for attr_name, attribute in controller_api.attributes.items(): + if not attribute.enabled: + # The IOC is built before the GUI, so anything it could not + # serve has already said so - don't draw a control for it. + continue + try: signal = self._get_attribute_component( controller_api.path, @@ -189,6 +194,9 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree: components.append(signal) for name, command in controller_api.command_methods.items(): + if not command.enabled: + continue + signal = self._get_command_component(controller_api.path, name) match command: diff --git a/src/fastcs/transports/epics/pva/ioc.py b/src/fastcs/transports/epics/pva/ioc.py index a3816fe77..4bfb370d6 100644 --- a/src/fastcs/transports/epics/pva/ioc.py +++ b/src/fastcs/transports/epics/pva/ioc.py @@ -4,6 +4,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI +from fastcs.logging import logger from fastcs.transports.epics.util import pv_prefix_from_path from fastcs.util import snake_to_pascal @@ -40,6 +41,18 @@ def parse_attributes(root_controller_api: ControllerAPI) -> StaticProvider: provider.add(f"{full_pv_name}", attribute_pv) for attr_name, method in controller_api.command_methods.items(): + if not method.is_void: + # As for CA: PVA has no typed-call representation either, so a + # typed command is skipped with a warning (ADR 0015). + logger.warning( + "EPICS PVA transport cannot serve a command that takes " + "arguments or returns a value", + command=attr_name, + signature=str(method.signature), + ) + method.enabled = False + continue + full_pv_name = f"{pv_prefix}:{snake_to_pascal(attr_name)}" command_pv = make_command_pv(method.fn) provider.add(f"{full_pv_name}", command_pv) diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index 0c74ad27d..4ead1aa18 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable, Coroutine +from inspect import Parameter, Signature from typing import Any import strawberry @@ -12,6 +13,7 @@ from fastcs.datatypes.datatype import DType_T from fastcs.exceptions import FastCSError from fastcs.logging import intercept_std_logger +from fastcs.methods import Command from .options import GraphQLServerOptions @@ -112,7 +114,7 @@ def _process_attributes(self, api: ControllerAPI): def _process_commands(self, controller_api: ControllerAPI): """Create mutations from api commands""" for name, method in controller_api.command_methods.items(): - self.mutations.append(strawberry.mutation(_wrap_command(name, method.fn))) + self.mutations.append(strawberry.mutation(_wrap_command(name, method))) def _process_sub_apis(self, root_controller_api: ControllerAPI): """Recursively add fields from the queries and mutations of sub apis""" @@ -181,13 +183,35 @@ def _dynamic_field(): return strawberry.field(_dynamic_field) -def _wrap_command(method_name: str, method: Callable) -> Callable[..., Awaitable[bool]]: +def _wrap_command(method_name: str, command: Command) -> Callable[..., Awaitable[Any]]: """Wrap a command in a function with annotations for strawberry""" + argument_names = [ + parameter.name for parameter in command.signature.parameters.values() + ] + return_datatype = command.return_datatype + # A void command has no value to give back, so it reports that it ran. + return_annotation = bool if return_datatype is None else return_datatype - async def _dynamic_f() -> bool: - await method() - return True + async def _dynamic_f(**kwargs): + result = await command.fn(*(kwargs[name] for name in argument_names)) + return True if return_datatype is None else result _dynamic_f.__name__ = method_name + # Strawberry builds the mutation's arguments and result by introspecting the + # resolver, so the command's arguments have to show up in both the signature + # and the annotations of a function that does not literally declare them. + _dynamic_f.__signature__ = Signature( # type: ignore[attr-defined] + [ + Parameter(name, Parameter.POSITIONAL_OR_KEYWORD, annotation=argument_type) + for name, argument_type in zip( + argument_names, command.argument_types, strict=True + ) + ], + return_annotation=return_annotation, + ) + _dynamic_f.__annotations__ = dict( + zip(argument_names, command.argument_types, strict=True) + ) + _dynamic_f.__annotations__["return"] = return_annotation return _dynamic_f diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index a8ca359d8..4f54195c9 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -3,13 +3,14 @@ import uvicorn from fastapi import FastAPI -from pydantic import create_model +from pydantic import BaseModel, create_model from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI from fastcs.datatypes.datatype import DType_T from fastcs.logging import intercept_std_logger -from fastcs.methods import CommandCallback +from fastcs.methods import Command +from fastcs.util import snake_to_pascal from .options import RestServerOptions from .util import ( @@ -142,13 +143,52 @@ def _add_attribute_api_routes(app: FastAPI, root_controller_api: ControllerAPI) ) +def _command_arguments_body(name: str, command: Command) -> type[BaseModel]: + """A pydantic model of a command's positional arguments, as a request body.""" + parameters = list(command.signature.parameters.values()) + # key=(type, ...) to declare a field without default value + fields: dict[str, Any] = { + parameter.name: (argument_type, ...) + for parameter, argument_type in zip( + parameters, command.argument_types, strict=True + ) + } + return create_model(f"Call{snake_to_pascal(name)}Arguments", **fields) + + +def _command_response_body(name: str, return_datatype: type) -> type[BaseModel]: + fields: dict[str, Any] = {"value": (return_datatype, ...)} + return create_model(f"Call{snake_to_pascal(name)}Result", **fields) + + def _wrap_command( - method: CommandCallback, -) -> Callable[..., Coroutine[None, None, None]]: - async def command() -> None: - await method() + name: str, command: Command +) -> Callable[..., Coroutine[None, None, dict[str, object] | None]]: + """Wrap a command in a route handler that carries its arguments and result.""" + argument_names = [ + parameter.name for parameter in command.signature.parameters.values() + ] + returns_a_value = command.return_datatype is not None + + if not argument_names: + + async def call() -> dict[str, object] | None: + result = await command.fn() + return {"value": result} if returns_a_value else None + + return call + + async def call_with_arguments(request) -> dict[str, object] | None: + arguments = [getattr(request, argument) for argument in argument_names] + result = await command.fn(*arguments) + return {"value": result} if returns_a_value else None + + # Fast api uses type annotations for validation, schema, conversions + call_with_arguments.__annotations__["request"] = _command_arguments_body( + name, command + ) - return command + return call_with_arguments def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> None: @@ -157,10 +197,18 @@ def _add_command_api_routes(app: FastAPI, root_controller_api: ControllerAPI) -> for name, method in controller_api.command_methods.items(): cmd_name = name.replace("_", "-") - route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name + route = f"{'/'.join(path)}/{cmd_name}" if path else cmd_name + return_datatype = method.return_datatype app.add_api_route( f"/{route}", - _wrap_command(method.fn), + _wrap_command(name, method), methods=["PUT"], - status_code=204, + # A command that gives something back has a body to return, so + # it answers 200 rather than 204 No Content. + status_code=200 if return_datatype is not None else 204, + response_model=( + _command_response_body(name, return_datatype) + if return_datatype is not None + else None + ), ) diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 553fe3d92..80aae1d2f 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -8,7 +8,8 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.methods import CommandCallback +from fastcs.logging import logger +from fastcs.methods import Command from .options import TangoDSROptions from .util import ( @@ -108,19 +109,50 @@ def _collect_dev_attributes( return collection +# Tango commands carry at most one input value, so a command taking more than +# one argument has no faithful representation and is skipped (ADR 0015). +TANGO_MAX_COMMAND_ARGUMENTS = 1 + +TANGO_COMMAND_DTYPES: tuple[type, ...] = (bool, int, float, str) +"""The command argument and return types Tango can carry. + +An enum is left out: Tango has no command-level enum, and picking name-or-index +for it would be a guess a driver author cannot see or override. +""" + + +def _unservable_reason(command: Command) -> str | None: + """Why Tango cannot serve this command, or ``None`` if it can.""" + if len(command.argument_types) > TANGO_MAX_COMMAND_ARGUMENTS: + return "a Tango command takes at most one argument" + + unsupported = [ + datatype + for datatype in (*command.argument_types, command.return_datatype) + if datatype is not None and datatype not in TANGO_COMMAND_DTYPES + ] + if unsupported: + names = ", ".join(datatype.__name__ for datatype in unsupported) + return f"Tango commands do not carry {names}" + + return None + + def _wrap_command_f( method_name: str, - method: CommandCallback, + command: Command, controller_api: ControllerAPI, loop: asyncio.AbstractEventLoop, -) -> Callable[..., Awaitable[None]]: - async def _dynamic_f(tango_device: Device) -> None: +) -> Callable[..., Awaitable[Any]]: + takes_argument = bool(command.argument_types) + + async def _dynamic_f(tango_device: Device, *args) -> Any: tango_device.info_stream( f"called {'_'.join(controller_api.path)} f method: {method_name}" ) - coro = method() - await _run_threadsafe_blocking(coro, loop) + coro = command.fn(*args) if takes_argument else command.fn() + return await _run_threadsafe_blocking(coro, loop) _dynamic_f.__name__ = method_name return _dynamic_f @@ -136,10 +168,22 @@ def _collect_dev_commands( path = controller_api.path[root_depth:] for name, method in controller_api.command_methods.items(): + if (reason := _unservable_reason(method)) is not None: + logger.warning( + "Tango transport cannot serve this command", + command=name, + signature=str(method.signature), + reason=reason, + ) + method.enabled = False + continue + cmd_name = name.title().replace("_", "") d_cmd_name = f"{'_'.join(path)}_{cmd_name}" if path else cmd_name collection[d_cmd_name] = server.command( - f=_wrap_command_f(d_cmd_name, method.fn, controller_api, loop) + f=_wrap_command_f(d_cmd_name, method, controller_api, loop), + dtype_in=method.argument_types[0] if method.argument_types else None, + dtype_out=method.return_datatype, ) return collection diff --git a/tests/test_methods.py b/tests/test_methods.py index a3e990996..b0d9681a5 100644 --- a/tests/test_methods.py +++ b/tests/test_methods.py @@ -1,3 +1,5 @@ +from inspect import signature + import pytest from fastcs.controllers import Controller @@ -14,12 +16,6 @@ def sync_do_nothing(): with pytest.raises(TypeError): Method(sync_do_nothing) # type: ignore - async def do_nothing_with_return() -> int: - return 1 - - with pytest.raises(TypeError): - Method(do_nothing_with_return) # type: ignore - async def do_nothing(): """Do nothing.""" pass @@ -28,6 +24,21 @@ async def do_nothing(): assert method.docstring == "Do nothing." assert method.group == "Nothing" + assert method.signature == signature(do_nothing) + + +def test_a_scan_takes_no_arguments_and_returns_nothing(): + async def scan_with_return() -> int: + return 1 + + with pytest.raises(TypeError, match="Scan method return type must be None"): + Scan(scan_with_return, 1.0) # type: ignore + + async def scan_with_argument(arg: int): + pass + + with pytest.raises(TypeError, match="Scan method cannot have arguments"): + Scan(scan_with_argument, 1.0) # type: ignore @pytest.mark.asyncio @@ -75,3 +86,93 @@ async def update_nothing_with_arg(self, arg): assert scan.period == 1.0 await scan() + + +@pytest.mark.asyncio +async def test_a_command_can_take_arguments_and_return_a_value(): + class TestController(Controller): + async def move_to(self, position: float, wait: bool) -> str: + return f"moved to {position}, waited {wait}" + + command = UnboundCommand(TestController.move_to).bind(TestController()) + + assert command.argument_types == (float, bool) + assert command.return_datatype is str + assert not command.is_void + assert await command(1.5, True) == "moved to 1.5, waited True" + + +@pytest.mark.asyncio +async def test_a_void_command_says_so(): + class TestController(Controller): + async def stop(self): + pass + + command = UnboundCommand(TestController.stop).bind(TestController()) + + assert command.argument_types == () + assert command.return_datatype is None + assert command.is_void + + +def test_command_arguments_must_be_annotated(): + class TestController(Controller): + async def move_to(self, position): + pass + + with pytest.raises(TypeError, match="Argument 'position'.*has no type annotation"): + UnboundCommand(TestController.move_to) + + +def test_command_arguments_must_be_a_supported_type(): + class TestController(Controller): + async def move_to(self, position: list[float]): + pass + + with pytest.raises(TypeError, match="Argument 'position'.*unsupported type"): + UnboundCommand(TestController.move_to) + + +def test_command_return_must_be_a_supported_type(): + class TestController(Controller): + async def measure(self) -> list[float]: + return [] + + with pytest.raises(TypeError, match="Return value.*unsupported type"): + UnboundCommand(TestController.measure) + + +def test_command_arguments_are_positional(): + class TestController(Controller): + async def move_to(self, *, position: float): + pass + + with pytest.raises(TypeError, match="keyword-only argument 'position'"): + UnboundCommand(TestController.move_to) + + +def test_command_arguments_must_be_fully_known(): + class TestController(Controller): + async def move_to(self, *args: float): + pass + + with pytest.raises(TypeError, match=r"takes \*args or \*\*kwargs"): + UnboundCommand(TestController.move_to) + + +@pytest.mark.asyncio +async def test_command_arguments_survive_binding(): + """The signature a transport reads must be the bound one, without ``self``.""" + + class TestController(Controller): + seen: list[float] = [] + + async def move_to(self, position: float) -> None: + self.seen.append(position) + + controller = TestController() + command = UnboundCommand(TestController.move_to).bind(controller) + + assert list(command.signature.parameters) == ["position"] + await command(2.5) + assert controller.seen == [2.5] diff --git a/tests/test_typed_commands.py b/tests/test_typed_commands.py new file mode 100644 index 000000000..36e6b3b82 --- /dev/null +++ b/tests/test_typed_commands.py @@ -0,0 +1,227 @@ +"""Serving commands that take arguments and return values, per transport. + +Each transport declares what it can carry: REST and GraphQL round-trip a typed +call, Tango carries at most one argument, and the EPICS transports are void-only +and skip anything else with a warning rather than refusing to serve the +controller (ADR 0015). +""" + +import asyncio +import enum + +import pytest +from fastapi.testclient import TestClient + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller, ControllerAPI +from fastcs.datatypes import Float +from fastcs.methods import command +from fastcs.transports.epics.ca.ioc import EpicsCAIOC +from fastcs.transports.epics.gui import EpicsGUI +from fastcs.transports.epics.pva.ioc import parse_attributes +from fastcs.transports.graphql.transport import GraphQLTransport +from fastcs.transports.rest.transport import RestTransport +from fastcs.transports.tango.dsr import _collect_dev_commands, _unservable_reason + + +class TypedCommandController(Controller): + """A controller with one command of each shape.""" + + calls: list[tuple] = [] + + # The GraphQL transport refuses an API with nothing to read + position = AttrR(Float()) + + @command() + async def stop(self) -> None: + self.calls.append(()) + + @command() + async def move_to(self, position: float, wait: bool) -> None: + self.calls.append((position, wait)) + + @command() + async def measure(self) -> float: + return 1.5 + + @command() + async def scale(self, factor: float) -> float: + return factor * 2 + + +@pytest.fixture +def controller_api() -> ControllerAPI: + TypedCommandController.calls = [] + return TypedCommandController()._build_api(["DEVICE"]) + + +def rest_client(controller_api: ControllerAPI) -> TestClient: + transport = RestTransport() + transport.connect([controller_api], asyncio.AbstractEventLoop()) + return TestClient(transport._server._app) + + +class TestRest: + def test_void_command_answers_no_content(self, controller_api): + with rest_client(controller_api) as client: + assert client.put("/DEVICE/stop").status_code == 204 + + def test_arguments_are_taken_from_the_request_body(self, controller_api): + with rest_client(controller_api) as client: + response = client.put( + "/DEVICE/move-to", json={"position": 2.5, "wait": True} + ) + + assert response.status_code == 204 + assert TypedCommandController.calls == [(2.5, True)] + + def test_a_missing_argument_is_rejected(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/move-to", json={"position": 2.5}) + + assert response.status_code == 422 + assert TypedCommandController.calls == [] + + def test_return_value_comes_back_in_the_body(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/measure") + + assert response.status_code == 200 + assert response.json() == {"value": 1.5} + + def test_arguments_and_a_return_value_together(self, controller_api): + with rest_client(controller_api) as client: + response = client.put("/DEVICE/scale", json={"factor": 3.0}) + + assert response.status_code == 200 + assert response.json() == {"value": 6.0} + + +class TestGraphQL: + @pytest.fixture + def client(self, controller_api) -> TestClient: + transport = GraphQLTransport() + transport.connect([controller_api], asyncio.AbstractEventLoop()) + return TestClient(transport._server._app) + + def query(self, client: TestClient, mutation: str): + response = client.post("/graphql", json={"query": f"mutation {{ {mutation} }}"}) + assert response.status_code == 200 + body = response.json() + assert "errors" not in body, body["errors"] + return body["data"] + + def test_void_command_reports_that_it_ran(self, client): + assert self.query(client, "DEVICE { stop }") == {"DEVICE": {"stop": True}} + + def test_arguments_are_mutation_arguments(self, client): + assert self.query(client, "DEVICE { moveTo(position: 2.5, wait: true) }") == { + "DEVICE": {"moveTo": True} + } + assert TypedCommandController.calls == [(2.5, True)] + + def test_return_value_is_the_mutation_result(self, client): + assert self.query(client, "DEVICE { scale(factor: 3.0) }") == { + "DEVICE": {"scale": 6.0} + } + + +class TestEpicsCA: + def test_typed_commands_are_skipped_and_void_ones_are_not(self, controller_api): + """A typed command must not stop the void ones being served.""" + EpicsCAIOC([controller_api], aliases={}) + + assert { + name: method.enabled + for name, method in controller_api.command_methods.items() + } == { + "stop": True, + "move_to": False, + "measure": False, + "scale": False, + } + + def test_skipping_says_why(self, controller_api, loguru_caplog): + EpicsCAIOC([controller_api], aliases={}) + + assert ( + "EPICS CA transport cannot serve a command that takes arguments or " + "returns a value" in loguru_caplog.text + ) + + +class TestTango: + """Tango carries one argument at most, and no enum, so it declares that.""" + + def test_serves_a_void_command(self, controller_api): + assert _unservable_reason(controller_api.command_methods["stop"]) is None + + def test_serves_one_argument_and_a_return_value(self, controller_api): + assert _unservable_reason(controller_api.command_methods["scale"]) is None + + def test_refuses_more_than_one_argument(self, controller_api): + assert ( + _unservable_reason(controller_api.command_methods["move_to"]) + == "a Tango command takes at most one argument" + ) + + def test_refuses_a_datatype_it_cannot_carry(self): + class Colour(enum.Enum): + RED = "red" + + class EnumCommandController(Controller): + @command() + async def set_colour(self, colour: Colour) -> None: + pass + + api = EnumCommandController()._build_api(["DEVICE"]) + + assert ( + _unservable_reason(api.command_methods["set_colour"]) + == "Tango commands do not carry Colour" + ) + + +class TestEpicsPva: + @pytest.mark.asyncio + async def test_typed_commands_are_skipped_and_void_ones_are_not( + self, controller_api + ): + provider = parse_attributes(controller_api) + + assert "DEVICE:Stop" in provider.keys() + assert "DEVICE:MoveTo" not in provider.keys() + assert { + name: method.enabled + for name, method in controller_api.command_methods.items() + } == { + "stop": True, + "move_to": False, + "measure": False, + "scale": False, + } + + +class TestEpicsGui: + def test_a_command_the_ioc_skipped_gets_no_widget(self, controller_api): + """The IOC is built before the GUI, so a skipped command has said so.""" + EpicsCAIOC([controller_api], aliases={}) + + components = EpicsGUI(controller_api).extract_api_components(controller_api) + + assert [component.name for component in components] == ["Position", "Stop"] + + def test_a_disabled_attribute_gets_no_widget(self, controller_api): + controller_api.attributes["position"].enabled = False + + components = EpicsGUI(controller_api).extract_api_components(controller_api) + + assert "Position" not in [component.name for component in components] + + +class TestTangoCollection: + def test_only_servable_commands_are_collected(self, controller_api, mocker): + collection = _collect_dev_commands(controller_api, mocker.MagicMock()) + + assert sorted(collection) == ["Measure", "Scale", "Stop"] + assert not controller_api.command_methods["move_to"].enabled From 997aea4430c2ae52ae49fba313491f74e3b04772 Mon Sep 17 00:00:00 2001 From: "Tom C (DLS)" <101418278+coretl@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:23:34 +0100 Subject: [PATCH 34/36] attributes: replace the DataType family with python types and `*Meta` typed dicts (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * attributes(#413): replace DataType family with python types + *Meta typed dicts An attribute's datatype is now the python type it holds - `float`, an Enum subclass, `Array1D[np.int32]`, `Table` - and everything that used to hang off a `DataType` instance travels separately as metadata on `attr.meta`. - New `fastcs.datatypes`: `Array1D`/`Table` datatype spellings, the per-datatype `*Meta` typed dicts (plus the superset `Meta`), nested `NumericLimits`, and the validation the `DataType` classes used to do. - `Attr*` constructors are overloaded per datatype, so `AttrRW(str, precision=3)` is a static type error; `validate_meta` is the runtime counterpart for metadata that arrives without a static check. - Naming pass (ADR 0017): `prec` -> `precision`, and the flat `min`/`max`/`min_alarm`/`max_alarm` become nested control/display/alarm/warning limits with inheritance. - Every transport repointed from `attr.datatype.*` to `attr.dtype` + `attr.meta`; `add_update_datatype_callback` becomes `add_update_meta_callback`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * fix(#413): repoint a p4p enum assertion and unresolvable doc references The p4p enum test still read `attr.datatype.members`; it cannot run in the sandbox (no PVA socket family), so CI was the first to see it. Three new docstrings also referenced `ControllerFiller`, which does not exist until #394, and an ambiguous `fastcs.datatypes.meta` - sphinx builds with warnings as errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * review(#413): address review nits — TypeVar placement, test tidy-ups, untrack opis - `datatypes/types.py`: `Enum_T`, `Array_T` and `Inferred_T` move up to sit with `DType_T`/`NumpyScalar_T` rather than trailing the module. - `tests/test_datatypes.py`: `_TABLE_META` and a module-level `Colour` enum move above the tests, and `test_resolve_datatype_takes_an_enum_class` folds into `test_resolve_datatype` as another parameter. - `tests/transports/epics/ca/test_softioc.py`: drop the unneeded `attribute.meta = {}` on the unsupported-datatype mock. - Stop tracking the generated `opis/` GUI files, gitignore the directory, and give `example_p4p_ioc` the same `gui_options` as `example_softioc`. Co-Authored-By: Claude * docs(#413): document npt.NDArray as an array spelling, and pin it `npt.NDArray[np.int32]` already resolves the same way as `Array1D[np.int32]` - it is the same subscripted `np.ndarray` alias with an unbounded shape - and neither spelling needs `shape` or `array_dtype`. Nothing said so, and no test held it, so both are added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019hVTucKg9bsV4LmSGdumAd * fix: amend datatype in tests --------- Co-authored-by: Claude Co-authored-by: claude Co-authored-by: Shihab S <162436767+shihab-dls@users.noreply.github.com> Co-authored-by: Shihab Suliman --- .gitignore | 3 + docs/explanations/controllers.md | 9 +- docs/explanations/datatypes.md | 287 ++++++------- docs/explanations/transports.md | 22 +- docs/how-to/arrange-epics-screens.md | 20 +- docs/how-to/table-waveform-data.md | 76 ++-- docs/how-to/update-attributes-from-device.md | 25 +- docs/how-to/wait-methods.md | 15 +- docs/snippets/dynamic.py | 16 +- docs/snippets/static03.py | 3 +- docs/snippets/static04.py | 3 +- docs/snippets/static05.py | 3 +- docs/snippets/static06.py | 3 +- docs/snippets/static07.py | 3 +- docs/snippets/static08.py | 5 +- docs/snippets/static09.py | 7 +- docs/snippets/static10.py | 15 +- docs/snippets/static11.py | 17 +- docs/snippets/static12.py | 23 +- docs/snippets/static13.py | 23 +- docs/snippets/static14.py | 23 +- docs/snippets/static15.py | 23 +- docs/tutorials/static-drivers.md | 2 +- src/fastcs/attributes/_infer_datatype.py | 41 +- src/fastcs/attributes/attr_r.py | 119 +++++- src/fastcs/attributes/attr_rw.py | 125 +++++- src/fastcs/attributes/attr_w.py | 107 ++++- src/fastcs/attributes/attribute.py | 147 +++++-- src/fastcs/controllers/base_controller.py | 4 +- src/fastcs/datatypes/__init__.py | 39 +- src/fastcs/datatypes/_numeric.py | 38 -- src/fastcs/datatypes/_util.py | 21 +- src/fastcs/datatypes/bool.py | 16 - src/fastcs/datatypes/datatype.py | 94 ----- src/fastcs/datatypes/enum.py | 37 -- src/fastcs/datatypes/float.py | 24 -- src/fastcs/datatypes/int.py | 12 - src/fastcs/datatypes/limits.py | 83 ++++ src/fastcs/datatypes/meta.py | 115 ++++++ src/fastcs/datatypes/string.py | 33 -- src/fastcs/datatypes/table.py | 40 -- src/fastcs/datatypes/types.py | 144 +++++++ src/fastcs/datatypes/validation.py | 220 ++++++++++ src/fastcs/datatypes/waveform.py | 46 --- src/fastcs/demo/eiger.py | 27 +- src/fastcs/demo/temperature_attr.py | 12 +- src/fastcs/transports/epics/ca/ioc.py | 15 +- src/fastcs/transports/epics/ca/util.py | 389 +++++++++--------- src/fastcs/transports/epics/gui.py | 89 ++-- .../transports/epics/pva/_pv_handlers.py | 12 +- src/fastcs/transports/epics/pva/gui.py | 71 ++-- src/fastcs/transports/epics/pva/types.py | 208 +++++----- src/fastcs/transports/graphql/graphql.py | 8 +- src/fastcs/transports/rest/rest.py | 12 +- src/fastcs/transports/rest/util.py | 42 +- src/fastcs/transports/tango/dsr.py | 10 +- src/fastcs/transports/tango/util.py | 118 +++--- tests/assertable_controller.py | 3 +- tests/benchmarking/controller.py | 5 +- tests/conftest.py | 13 +- tests/demo/test_eiger.py | 11 +- tests/example_p4p_ioc.py | 35 +- tests/example_softioc.py | 9 +- tests/test_attribute_logging.py | 9 +- tests/test_attributes.py | 172 ++++++-- tests/test_control_system.py | 7 +- tests/test_controllers.py | 35 +- tests/test_datatypes.py | 209 +++++++--- tests/test_launch.py | 3 +- tests/test_multi_controller.py | 9 +- tests/test_typed_commands.py | 3 +- tests/transports/epics/ca/test_ca_util.py | 98 +++-- tests/transports/epics/ca/test_gui.py | 58 ++- .../transports/epics/ca/test_initial_value.py | 66 +-- tests/transports/epics/ca/test_softioc.py | 107 ++--- tests/transports/epics/pva/test_p4p.py | 57 ++- tests/transports/epics/pva/test_pva_gui.py | 45 +- tests/transports/epics/test_emission.py | 5 +- tests/transports/graphQL/test_graphql.py | 13 +- tests/transports/rest/test_rest.py | 22 +- tests/transports/tango/test_dsr.py | 22 +- 81 files changed, 2492 insertions(+), 1668 deletions(-) delete mode 100644 src/fastcs/datatypes/_numeric.py delete mode 100644 src/fastcs/datatypes/bool.py delete mode 100644 src/fastcs/datatypes/datatype.py delete mode 100644 src/fastcs/datatypes/enum.py delete mode 100644 src/fastcs/datatypes/float.py delete mode 100644 src/fastcs/datatypes/int.py create mode 100644 src/fastcs/datatypes/limits.py create mode 100644 src/fastcs/datatypes/meta.py delete mode 100644 src/fastcs/datatypes/string.py delete mode 100644 src/fastcs/datatypes/table.py create mode 100644 src/fastcs/datatypes/types.py create mode 100644 src/fastcs/datatypes/validation.py delete mode 100644 src/fastcs/datatypes/waveform.py diff --git a/.gitignore b/.gitignore index 8f3fdc264..cdae377b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /*bob +# GUI files emitted by the example IOCs in tests/ +/opis/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/docs/explanations/controllers.md b/docs/explanations/controllers.md index 3d18defd5..30a09da0a 100644 --- a/docs/explanations/controllers.md +++ b/docs/explanations/controllers.md @@ -37,13 +37,12 @@ sets it back to `True`. ```python from fastcs.controllers import Controller from fastcs.attributes import AttrR, AttrRW -from fastcs.datatypes import Float, String from fastcs.methods import scan class TemperatureController(Controller): - temperature = AttrR(Float(units="degC")) - setpoint = AttrRW(Float(units="degC")) + temperature = AttrR(float, units="degC") + setpoint = AttrRW(float, units="degC") async def connect(self): self._client = await DeviceClient.connect(self._host, self._port) @@ -73,7 +72,7 @@ controller also has connection logic, the parent must invoke it explicitly: ```python class ChannelController(Controller): - value = AttrR(Float()) + value = AttrR(float) async def connect(self): ... @@ -108,7 +107,7 @@ from fastcs.controllers import Controller, ControllerVector class ChannelController(Controller): - value = AttrR(Float()) + value = AttrR(float) class RootController(Controller): diff --git a/docs/explanations/datatypes.md b/docs/explanations/datatypes.md index fb1d81740..707286826 100644 --- a/docs/explanations/datatypes.md +++ b/docs/explanations/datatypes.md @@ -1,81 +1,83 @@ # Datatypes -FastCS uses a datatype system to map Python types to attributes with additional -metadata for validation, serialization, and transport handling. +An attribute's datatype is a **python type**. Everything else that describes the +attribute - precision, units, limits, array shape - is **metadata**, passed as +keyword arguments and held on the attribute as `attr.meta`. + +```python +from fastcs.attributes import AttrRW + +temperature = AttrRW(float, precision=3, units="degC") +``` + +There is no `DataType` object to construct and no wrapper to unwrap: `attr.dtype` +is `float`, and `attr.meta` is a plain typed dict. ## Supported Types -FastCS defines `DType` as the union of supported Python types: +FastCS defines `DType` as the union of supported python types: -:::{literalinclude} ../../src/fastcs/datatypes/datatype.py +:::{literalinclude} ../../src/fastcs/datatypes/types.py :start-at: "DType = (" :end-at: ")" ::: -Each has a corresponding `DataType` class. - ## Scalar Datatypes -### Int and Float +`int`, `float`, `bool` and `str` are used directly. Which metadata each accepts is +given by its `*Meta` typed dict: -Both inherit from `_Numeric`, which adds support for bounds and alarm limits: - -:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py -:start-at: "@dataclass(frozen=True)" -:end-at: "max_alarm:" -::: +| Datatype | Metadata | +| -------- | ----------------------------------------------------- | +| `bool` | `description`, `group` | +| `int` | `description`, `group`, `units`, `limits` | +| `float` | `description`, `group`, `units`, `limits`, `precision` | +| `str` | `description`, `group`, `length` | -### Bool +`precision` is the number of decimal places a float is rounded to and displayed +with; it defaults to 2. `length` truncates a string during validation, and is +also a hint to transports sizing their records - the EPICS CA transport uses it +for string waveform records. -Maps to Python `bool`. Initial value is `False`. +The constructors are overloaded per datatype, so metadata a datatype has no use +for is a type error rather than a field silently ignored: -:::{literalinclude} ../../src/fastcs/datatypes/bool.py -:pyobject: Bool -::: - -### String - -Maps to Python `str`. Has an optional `length` field that truncates values during validation. It is also used as a hint by some transports to configure the size of string records (e.g. EPICS CA string waveform records). - -:::{literalinclude} ../../src/fastcs/datatypes/string.py -:pyobject: String -::: +```python +AttrRW(float, precision=3) # fine +AttrRW(str, precision=3) # type error, and raises at construction +``` ## Enum Datatype -Wraps a Python `enum.Enum` class: - -:::{literalinclude} ../../src/fastcs/datatypes/enum.py -:pyobject: Enum -::: - -The `Enum` datatype provides helper properties: - -- `members`: List of enum values -- `names`: List of enum member names -- `index_of(value)`: Get the index of a value in the members list - -:::{note} -FastCS uses enum **member names** (not values) when exposing choices to transports and -PVI. This means member names are the user-friendly UI strings while values are the -strings sent to the device: +An `enum.Enum` subclass is used directly as the datatype; the choices come from +the class, so there is no metadata to give: ```python -class DetectorStatus(StrEnum): +import enum +from fastcs.attributes import AttrR + +class DetectorStatus(enum.StrEnum): Idle = "IDLE_STATE" Running = "RUNNING_STATE" Error = "ERROR_STATE" + +status = AttrR(DetectorStatus) ``` -Clients will see the choices as `["Idle", "Running", "Error"]`. +:::{note} +FastCS uses enum **member names** (not values) when exposing choices to transports and +PVI. This means member names are the user-friendly UI strings while values are the +strings sent to the device. For the enum above, clients see the choices as +`["Idle", "Running", "Error"]`. For UI strings with spaces, use the functional `enum.Enum` API with a dict: ```python import enum -from fastcs.datatypes import Enum -DetectorStatus = Enum(enum.Enum("DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"})) +DetectorStatus = enum.Enum( + "DetectorStatus", {"Run Finished": "RUN_FINISHED", "In Progress": "IN_PROGRESS"} +) ``` Clients will see the choices as `["Run Finished", "In Progress"]`. @@ -83,91 +85,94 @@ Clients will see the choices as `["Run Finished", "In Progress"]`. ## Array Datatypes -### Waveform +### Array1D -For homogeneous numpy arrays (spectra, images): +For homogeneous numpy arrays. The element type rides on the datatype itself, and +the maximum shape is metadata: -:::{literalinclude} ../../src/fastcs/datatypes/waveform.py -:pyobject: Waveform +:::{literalinclude} ../../src/fastcs/datatypes/types.py +:start-at: "Array1D: TypeAlias" +:end-before: "class Table" ::: -Validation ensures the array fits within the declared shape and has the correct dtype. +```python +import numpy as np +import numpy.typing as npt +from fastcs.attributes import AttrR +from fastcs.datatypes import Array1D + +spectrum = AttrR(Array1D[np.float64], shape=(1000,)) +counts = AttrR(npt.NDArray[np.int32]) # numpy's own spelling, default shape +image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024)) +``` + +Validation ensures the array fits within the declared shape and has the correct +element type. `shape` defaults to `(2000,)`. ### Table For structured numpy arrays with named columns: -:::{literalinclude} ../../src/fastcs/datatypes/table.py +:::{literalinclude} ../../src/fastcs/datatypes/types.py :pyobject: Table ::: -The `structured_dtype` field is a list of `(name, dtype)` tuples following +The `structured_dtype` metadata is a list of `(name, dtype)` tuples following numpy's structured array conventions. -## Validation +## Limits -### Built-in Numeric Validation +Numeric limits are nested rather than flat, in four categories aligned with the +bluesky event-model: -`Int` and `Float` datatypes support min/max limits and alarm thresholds: +:::{literalinclude} ../../src/fastcs/datatypes/limits.py +:pyobject: NumericLimits +::: ```python from fastcs.attributes import AttrRW -from fastcs.datatypes import Int, Float +from fastcs.datatypes import Limits, NumericLimits -# Integer with bounds -count = AttrRW(Int(min=0, max=100)) - -# Float with units and alarm limits -temperature = AttrRW(Float( +temperature = AttrRW( + float, units="degC", - min=-273.15, # Absolute minimum - max=1000.0, # Absolute maximum - min_alarm=-50.0, # Warning below this - max_alarm=200.0, # Warning above this -)) + limits=NumericLimits( + control=Limits(-273.15, 1000.0), # what it may be driven to + display=Limits(0.0, 500.0), # what it is shown as spanning + alarm=Limits(-50.0, 200.0), # outside this it is in alarm + ), +) ``` -#### Validation Behavior - -```python -temp = Float(min=0.0, max=100.0) - -temp.validate(50.0) # Returns 50.0 -temp.validate(-10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0" -temp.validate(150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0" -``` +Only the **control** range rejects values. Display, alarm and warning are served +to clients - EPICS `LOPR`/`HOPR` and `DRVL`/`DRVH`, Tango's attribute +properties, the PVA display and alarm structures - but do not constrain a write. -### String Length +## Validation -Limit the display length of strings: +### Numeric limits ```python -from fastcs.datatypes import String +from fastcs.datatypes import Limits, Meta, NumericLimits, validate_value -# Limit display to 40 characters -status = AttrR(String(length=40)) -``` +meta = Meta(limits=NumericLimits(control=Limits(0.0, 100.0))) -:::{note} -The `length` parameter truncates values during validation and is also used by some -transports to configure their records, for example the EPICS CA transport uses it to -set the length of string waveform records. -::: +validate_value(float, meta, 50.0) # Returns 50.0 +validate_value(float, meta, -10.0) # Raises ValueError: "Value -10.0 is less than minimum 0.0" +validate_value(float, meta, 150.0) # Raises ValueError: "Value 150.0 is greater than maximum 100.0" +``` ### Type Coercion -All datatypes automatically coerce compatible types: +Values are coerced to the datatype: ```python -from fastcs.datatypes import Int, Float +from fastcs.datatypes import Meta, validate_value -int_type = Int() -int_type.validate("42") # Returns 42 (str -> int) -int_type.validate(3.7) # Returns 3 (float -> int, truncated) - -float_type = Float() -float_type.validate("3.14") # Returns 3.14 (str -> float) -float_type.validate(42) # Returns 42.0 (int -> float) +validate_value(int, Meta(), "42") # Returns 42 (str -> int) +validate_value(int, Meta(), 3.7) # Returns 3 (float -> int, truncated) +validate_value(float, Meta(), "3.14") # Returns 3.14 (str -> float) +validate_value(float, Meta(), 42) # Returns 42.0 (int -> float) ``` ### When Validation Runs @@ -180,9 +185,9 @@ Validation runs automatically when: ```python from fastcs.attributes import AttrRW -from fastcs.datatypes import Int +from fastcs.datatypes import Limits, NumericLimits -attr = AttrRW(Int(min=0, max=10), initial_value=5) +attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 10)), initial_value=5) # Updates are validated await attr.update(7) # OK @@ -193,68 +198,42 @@ await attr.set(3) # OK await attr.set(-1) # Raises ValueError ``` -## Transport Handling - -Transports are responsible for serializing datatypes appropriately for their protocol. -Each transport must handle all supported datatypes. The datatype's `dtype` property -and class type are used to determine serialization: - -- Scalars (`Int`, `Float`, `Bool`, `String`) serialize directly -- `Enum` values are typically serialized as integers (index) or strings (name) -- `Waveform` and `Table` arrays are serialized as lists or protocol-specific array types +Metadata itself is validated when the attribute is built, so a field that the +datatype has no use for fails fast even when it arrived without a static check - +from a declarative extras object, say: -## Creating Custom Datatypes - -All datatypes inherit from `DataType[DType_T]`, a generic frozen dataclass that defines -the interface for type handling: - -:::{literalinclude} ../../src/fastcs/datatypes/datatype.py -:start-at: "@dataclass(frozen=True)" -:end-at: "raise NotImplementedError()" -::: - -### Required Properties - -To create a custom datatype, subclass `DataType` or one of the existing datatypes and -implement the required properties: - -**`dtype`**: Returns the underlying Python type. This is used for type coercion in -`validate()` and for transport serialization. - -**`initial_value`**: Returns the default value used when an attribute is created -without an explicit initial value. - -### Overriding `validate()` - -The base `validate()` implementation attempts to cast incoming values to the target type: - -:::{literalinclude} ../../src/fastcs/datatypes/datatype.py -:pyobject: DataType.validate -::: +```python +AttrR(str, precision=3) +# TypeError: 'precision' is not valid metadata for str attribute - valid fields +# are description, group, length +``` -Subclasses can override this to add validation logic. The pattern is +## Transport Handling -1. Coerce input to help type casting succeed - e.g. `Waveform` calls `numpy.asarray(...)` -2. Call `super().validate(value)` to call parent implementation and perform the type cast -3. Perform any additional validation such as checking limits - e.g. `_Numeric` adds min/max validation: +Transports are responsible for serializing values appropriately for their +protocol, and each must handle every supported datatype. They dispatch on +`attr.dtype` and read what they serve from `attr.meta`: -:::{literalinclude} ../../src/fastcs/datatypes/_numeric.py -:pyobject: _Numeric.validate -::: +- Scalars (`int`, `float`, `bool`, `str`) serialize directly +- Enum values are typically serialized as integers (index) or strings (name) +- Arrays and tables are serialized as lists or protocol-specific array types -### Overriding `equal()` +An array and a table are both held as `np.ndarray`; what separates them is that a +table's metadata names its columns, so a transport that needs to tell them apart +checks for `structured_dtype` in `attr.meta`. -The `equal()` method is used by the `always` flag in attribute callbacks to determine -if a value has changed. The default uses Python's `==` operator, but array types -override this to use `numpy.array_equal()`: +## Adding a Datatype -:::{literalinclude} ../../src/fastcs/datatypes/waveform.py -:pyobject: Waveform.equal -::: +A datatype is a python type in `DType`, so adding one means widening that union +and teaching the pieces that dispatch on it: -### Transport Compatibility +1. Add the type to `DType` in `fastcs.datatypes.types`, and to `resolve_datatype` +2. Add a `*Meta` typed dict for the metadata it accepts, and map the datatype to + it in `meta_class_for` +3. Handle it in `validate_value`, `default_value` and `values_equal` +4. Add an overload to each of `AttrR`, `AttrW` and `AttrRW` so its metadata is + statically checked +5. Handle it in each transport -When creating a new datatype, existing transports will need to be updated to handle it, -unless the datatype inherits from a supported type. In the latter case, the transport -will use the parent class handling, while the custom datatype can add validation or -other behaviour on top. +Metadata alone needs much less: a new field on an existing `*Meta` is picked up +by `validate_meta` automatically, and only the transports that serve it change. diff --git a/docs/explanations/transports.md b/docs/explanations/transports.md index e722f891d..37edc2770 100644 --- a/docs/explanations/transports.md +++ b/docs/explanations/transports.md @@ -107,7 +107,7 @@ layer. |----------|-----------------|--------------|-----------|---------| | Readback | `add_readback_callback()` | `attr.update(value)` | Publish ↑ | Update protocol representation when the attribute's readback changes | | Setpoint | `add_setpoint_callback()` | `attr.set(value)` | Publish ↑ | Update protocol representation when the attribute's setpoint changes | -| Update Datatype | `add_update_datatype_callback()` | `datatype` property changes | Publish ↑ | Update protocol metadata when datatype changes | +| Update Metadata | `add_update_meta_callback()` | `meta` property changes | Publish ↑ | Update protocol metadata when it changes | | Set | `attr.set(value)` | Transport receives user input | Set ↓ | Forward write requests from protocol to attribute | ### Readback Callbacks @@ -129,9 +129,11 @@ The callback receives the new value and should update the protocol-specific representation (e.g., posting to a PV, updating a REST endpoint cache, publishing the change to a subscriber). -### Update Datatype Callbacks +### Update Metadata Callbacks -Use `add_update_datatype_callback()` to update protocol metadata when an attribute's datatype changes. This is useful for protocols that expose datatype metadata (like EPICS record fields). +Use `add_update_meta_callback()` to update protocol metadata when an attribute's +metadata changes. This is useful for protocols that expose that metadata (like EPICS +record fields). ```python def create_read(name, attribute): @@ -139,14 +141,18 @@ def create_read(name, attribute): attribute.add_readback_callback(update_protocol_value) - def update_protocol_metadata(datatype: DataType): - protocol_read.set_units(datatype.units) - protocol_read.set_limits(datatype.min, datatype.max) + def update_protocol_metadata(meta: Meta): + protocol_read.set_units(meta.get("units")) + limits = meta.get("limits") + if limits is not None: + protocol_read.set_limits(limits.control.low, limits.control.high) - attribute.add_update_datatype_callback(update_protocol_metadata) + attribute.add_update_meta_callback(update_protocol_metadata) ``` -The callback receives the new `DataType` instance and should update the protocol's metadata representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). +The callback receives the new `Meta` and should update the protocol's metadata +representation (e.g., EPICS record fields like `EGU`, `HOPR`, `LOPR`). Every field is +optional, so read them with `.get()`. ### Setpoint Callbacks diff --git a/docs/how-to/arrange-epics-screens.md b/docs/how-to/arrange-epics-screens.md index d7f1303f2..10b9c1649 100644 --- a/docs/how-to/arrange-epics-screens.md +++ b/docs/how-to/arrange-epics-screens.md @@ -16,17 +16,16 @@ box. ```python from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int from fastcs.methods import command class PowerSupplyController(Controller): - voltage = AttrRW(Float(), group="Output") - current = AttrRW(Float(), group="Output") - power = AttrR(Float(), group="Output") + voltage = AttrRW(float, group="Output") + current = AttrRW(float, group="Output") + power = AttrR(float, group="Output") - temperature = AttrR(Float(), group="Status") - fault_code = AttrR(Int(), group="Status") + temperature = AttrR(float, group="Status") + fault_code = AttrR(int, group="Status") @command(group="Actions") async def reset_faults(self) -> None: @@ -51,14 +50,13 @@ sub-screens. ```python from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int from fastcs.methods import command class ChannelController(Controller): - voltage = AttrRW(Float(), group="Output") - current = AttrRW(Float(), group="Output") - temperature = AttrR(Float(), group="Status") + voltage = AttrRW(float, group="Output") + current = AttrRW(float, group="Output") + temperature = AttrR(float, group="Status") @command(group="Actions") async def enable(self) -> None: @@ -66,7 +64,7 @@ class ChannelController(Controller): class MultiChannelPSU(Controller): - total_power = AttrR(Float()) + total_power = AttrR(float) @command() async def disable_all(self) -> None: diff --git a/docs/how-to/table-waveform-data.md b/docs/how-to/table-waveform-data.md index 8a768604f..a9cb4667d 100644 --- a/docs/how-to/table-waveform-data.md +++ b/docs/how-to/table-waveform-data.md @@ -1,53 +1,55 @@ -# Work with Table and Waveform Data +# Work with Table and Array Data -This guide shows how to use `Waveform` and `Table` datatypes for array-based data. +This guide shows how to use the `Array1D` and `Table` datatypes for array-based data. -## Waveform - Homogeneous Arrays +## Array1D - Homogeneous Arrays -Use `Waveform` for numpy arrays of a single data type (spectra, time series, images). - -### Basic 1D Waveform +Use `Array1D` for numpy arrays of a single element type (spectra, time series, images). ```python import numpy as np from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Waveform +from fastcs.datatypes import Array1D class SpectrumController(Controller): # 1D array of 1000 float64 values - spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + spectrum = AttrR(Array1D[np.float64], shape=(1000,)) - # Writable waveform - setpoints: AttrRW[np.ndarray] = AttrRW(Waveform(np.float64, shape=(100,))) + # Writable array + setpoints = AttrRW(Array1D[np.float64], shape=(100,)) ``` -### 2D Waveform (Images) +### 2D Arrays (Images) + +`Array1D` is, as the name says, one dimensional. An array of higher rank has no +ophyd-async-compatible spelling, so write it as `np.ndarray` with an explicit +`array_dtype`: ```python class CameraController(Controller): # 2D array for images (max 1024x1024 uint16) - image: AttrR[np.ndarray] = AttrR(Waveform(np.uint16, shape=(1024, 1024))) + image = AttrR(np.ndarray, array_dtype=np.uint16, shape=(1024, 1024)) # Smaller region of interest - roi: AttrRW[np.ndarray] = AttrRW(Waveform(np.uint16, shape=(256, 256))) + roi = AttrRW(np.ndarray, array_dtype=np.uint16, shape=(256, 256)) ``` -### Waveform Parameters +### Array Metadata -| Parameter | Type | Default | Description | +| Field | Type | Default | Description | |-----------|------|---------|-------------| -| `array_dtype` | `DTypeLike` | (required) | Numpy dtype (`np.float64`, `np.int32`, etc.) | +| `array_dtype` | `DTypeLike` | from the datatype subscript | Numpy element type (`np.float64`, `np.int32`, etc.) | | `shape` | `tuple[int, ...]` | `(2000,)` | Maximum array dimensions | -### Updating Waveforms +### Updating Arrays ```python from fastcs.methods import scan class SpectrumController(Controller): - spectrum: AttrR[np.ndarray] = AttrR(Waveform(np.float64, shape=(1000,))) + spectrum = AttrR(Array1D[np.float64], shape=(1000,)) @scan(period=0.1) async def read_spectrum(self): @@ -60,16 +62,16 @@ class SpectrumController(Controller): ### Shape Validation -Waveforms validate that data fits within the declared shape: +Arrays validate that data fits within the declared shape: ```python -wave = Waveform(np.float64, shape=(100,)) +spectrum = AttrR(Array1D[np.float64], shape=(100,)) # OK - fits within shape -wave.validate(np.array([1.0, 2.0, 3.0])) +spectrum.validate(np.array([1.0, 2.0, 3.0])) # Error - exceeds maximum shape -wave.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,) +spectrum.validate(np.arange(200)) # ValueError: shape (200,) exceeds maximum (100,) ``` ## Table - Structured Arrays @@ -87,16 +89,19 @@ from fastcs.datatypes import Table class MeasurementController(Controller): # Table with columns: name (string), value (float), valid (bool) - results: AttrR[np.ndarray] = AttrR(Table([ - ("name", "S32"), # 32-character string - ("value", np.float64), - ("valid", np.bool_), - ])) + results = AttrR( + Table, + structured_dtype=[ + ("name", "S32"), # 32-character string + ("value", np.float64), + ("valid", np.bool_), + ], + ) ``` -### Table Parameters +### Table Metadata -| Parameter | Type | Description | +| Field | Type | Description | |-----------|------|-------------| | `structured_dtype` | `list[tuple[str, DTypeLike]]` | List of (name, dtype) tuples | @@ -108,11 +113,14 @@ from fastcs.controllers import Controller from fastcs.datatypes import Table class ChannelController(Controller): - channel_data: AttrR[np.ndarray] = AttrR(Table([ - ("channel", np.int32), - ("temperature", np.float64), - ("status", "S10"), - ])) + channel_data = AttrR( + Table, + structured_dtype=[ + ("channel", np.int32), + ("temperature", np.float64), + ("status", "S10"), + ], + ) # Create data using numpy structured array data = np.array([ diff --git a/docs/how-to/update-attributes-from-device.md b/docs/how-to/update-attributes-from-device.md index f76fbc04e..b5b710965 100644 --- a/docs/how-to/update-attributes-from-device.md +++ b/docs/how-to/update-attributes-from-device.md @@ -15,7 +15,6 @@ and calls any update callbacks; there's no need to call `attr.update` yourself: ```python from fastcs.attributes import AttrR, AttrRW, NotPolled, Polled from fastcs.controllers import Controller -from fastcs.datatypes import Float, String class MyController(Controller): @@ -24,14 +23,14 @@ class MyController(Controller): super().__init__() self.temperature = AttrR( - Float(), getter=Polled(self._get_temperature, period=0.5) + float, getter=Polled(self._get_temperature, period=0.5) ) self.setpoint = AttrRW( - Float(), + float, getter=Polled(self._get_setpoint, period=1.0), setter=self._set_setpoint, ) - self.label = AttrR(String(), getter=NotPolled(self._get_label)) + self.label = AttrR(str, getter=NotPolled(self._get_label)) async def _get_temperature(self) -> float: response = await self._connection.send_query("T?\r\n") @@ -78,7 +77,6 @@ sibling attributes whose values have changed: ```python from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller -from fastcs.datatypes import Float class MyController(Controller): @@ -87,11 +85,11 @@ class MyController(Controller): super().__init__() self.setpoint = AttrRW( - Float(), getter=self._get_setpoint, setter=self._set_setpoint + float, getter=self._get_setpoint, setter=self._set_setpoint ) - self.actual_temperature = AttrR(Float(), getter=self._get_actual_temperature) - self.power = AttrR(Float(), getter=self._get_power) - self.status = AttrR(Float(), getter=self._get_status) + self.actual_temperature = AttrR(float, getter=self._get_actual_temperature) + self.power = AttrR(float, getter=self._get_power) + self.status = AttrR(float, getter=self._get_status) async def _get_setpoint(self) -> float: return float((await self._connection.send_query("S?\r\n")).strip()) @@ -134,12 +132,11 @@ import json from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float from fastcs.methods import scan class ChannelController(Controller): - voltage = AttrR(Float()) # No getter — updated by parent scan method + voltage = AttrR(float) # No getter — updated by parent scan method def __init__(self, index: int, connection): super().__init__(f"Ch{index:02d}") @@ -183,7 +180,6 @@ import json from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float from fastcs.methods import scan @@ -193,7 +189,7 @@ class ChannelController(Controller): self._cache = cache super().__init__(f"Ch{index:02d}") - self.voltage = AttrR(Float(), getter=Polled(self._get_voltage, period=0.1)) + self.voltage = AttrR(float, getter=Polled(self._get_voltage, period=0.1)) async def _get_voltage(self) -> float: return self._cache.get(self._index, 0.0) @@ -231,11 +227,10 @@ import asyncio from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float class SubscriptionController(Controller): - temperature = AttrR(Float()) + temperature = AttrR(float) def __init__(self, subscription_client): super().__init__() diff --git a/docs/how-to/wait-methods.md b/docs/how-to/wait-methods.md index e9ef09b94..ee59bc67b 100644 --- a/docs/how-to/wait-methods.md +++ b/docs/how-to/wait-methods.md @@ -10,12 +10,11 @@ Use `wait_for_value()` to pause execution until an attribute reaches an exact va ```python from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.methods import command class MotorController(Controller): - position: AttrR[int] = AttrR(Int()) - target: AttrR[int] = AttrR(Int()) + position: AttrR[int] = AttrR(int) + target: AttrR[int] = AttrR(int) @command() async def move_and_wait(self): @@ -37,11 +36,10 @@ takes the attribute value and returns `True` when the condition is satisfied: ```python from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float from fastcs.methods import command class TemperatureController(Controller): - temperature: AttrR[float] = AttrR(Float()) + temperature: AttrR[float] = AttrR(float) @command() async def wait_for_stable(self): @@ -89,13 +87,12 @@ import asyncio from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Float from fastcs.methods import command class MultiAxisController(Controller): - x_position = AttrR(Float()) - y_position = AttrR(Float()) - z_position = AttrR(Float()) + x_position = AttrR(float) + y_position = AttrR(float) + z_position = AttrR(float) @command() async def move_all_and_wait(self): diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py index 9a5cc4f55..4e7c00e72 100644 --- a/docs/snippets/dynamic.py +++ b/docs/snippets/dynamic.py @@ -6,7 +6,7 @@ from fastcs.attributes import Attribute, AttrR, AttrRW from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Bool, DataType, Float, Int, String +from fastcs.datatypes import DType from fastcs.launch import FastCS from fastcs.transports.epics.ca import EpicsCATransport @@ -35,16 +35,16 @@ class TemperatureControllerParameter(BaseModel): access_mode: Literal["r", "rw"] @property - def fastcs_datatype(self) -> DataType: + def fastcs_datatype(self) -> type[DType]: match self.type: case "bool": - return Bool() + return bool case "int": - return Int() + return int case "float": - return Float() + return float case "str": - return String() + return str def create_attributes( @@ -63,7 +63,7 @@ def create_attributes( datatype = parameter.fastcs_datatype command = parameter.command - async def getter(command=command, dtype=datatype.dtype): + async def getter(command=command, dtype=datatype): return await protocol.send_query(command, dtype) match parameter.access_mode: @@ -71,7 +71,7 @@ async def getter(command=command, dtype=datatype.dtype): attributes[name] = AttrR(datatype, getter=getter) case "rw": - async def setter(value, command=command, dtype=datatype.dtype): + async def setter(value, command=command, dtype=datatype): await protocol.send_command(command, value, dtype) attributes[name] = AttrRW(datatype, getter=getter, setter=setter) diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py index ca73b357c..82b2ee5fb 100644 --- a/docs/snippets/static03.py +++ b/docs/snippets/static03.py @@ -1,11 +1,10 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) fastcs = FastCS(TemperatureController(), []) diff --git a/docs/snippets/static04.py b/docs/snippets/static04.py index 345794ea9..c52801fab 100644 --- a/docs/snippets/static04.py +++ b/docs/snippets/static04.py @@ -1,12 +1,11 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics.ca.transport import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) epics_ca = EpicsCATransport() diff --git a/docs/snippets/static05.py b/docs/snippets/static05.py index 0d3b610af..2851dc6d0 100644 --- a/docs/snippets/static05.py +++ b/docs/snippets/static05.py @@ -2,14 +2,13 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) gui_options = EpicsGUIOptions(output_dir=Path("."), title="Demo Temperature Controller") diff --git a/docs/snippets/static06.py b/docs/snippets/static06.py index 269e3309e..f7bd33d15 100644 --- a/docs/snippets/static06.py +++ b/docs/snippets/static06.py @@ -3,14 +3,13 @@ from fastcs.attributes import AttrR from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): - device_id = AttrR(String()) + device_id = AttrR(str) def __init__(self, settings: IPConnectionSettings): super().__init__() diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py index 3bd0e04f3..2aea3bb76 100644 --- a/docs/snippets/static07.py +++ b/docs/snippets/static07.py @@ -3,7 +3,6 @@ from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport @@ -16,7 +15,7 @@ def __init__(self, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) async def _get_device_id(self) -> str: response = await self._connection.send_query("ID?\r\n") diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py index f2483679e..95363380b 100644 --- a/docs/snippets/static08.py +++ b/docs/snippets/static08.py @@ -4,7 +4,6 @@ from fastcs.attributes import AttrR, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport @@ -35,8 +34,8 @@ def __init__(self, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) async def _get_device_id(self) -> str: return await self._protocol.send_query("ID", str) diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py index 1a75ea678..10c07b334 100644 --- a/docs/snippets/static09.py +++ b/docs/snippets/static09.py @@ -4,7 +4,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport @@ -35,10 +34,10 @@ def __init__(self, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py index 3c02c402e..e6ea3292d 100644 --- a/docs/snippets/static10.py +++ b/docs/snippets/static10.py @@ -4,7 +4,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport @@ -34,14 +33,10 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) async def _get_start(self) -> int: @@ -65,10 +60,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py index 6dd4359f0..222d0cdda 100644 --- a/docs/snippets/static11.py +++ b/docs/snippets/static11.py @@ -5,7 +5,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.ca import EpicsCATransport @@ -40,17 +39,13 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) self.enabled = AttrRW( - Enum(OnOffEnum), + OnOffEnum, getter=Polled(self._get_enabled, period=0.2), setter=self._set_enabled, ) @@ -82,10 +77,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py index 818f0337c..db7ced42c 100644 --- a/docs/snippets/static12.py +++ b/docs/snippets/static12.py @@ -6,7 +6,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.methods import scan from fastcs.transports.epics import EpicsGUIOptions @@ -42,23 +41,19 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) self.enabled = AttrRW( - Enum(OnOffEnum), + OnOffEnum, getter=Polled(self._get_enabled, period=0.2), setter=self._set_enabled, ) - self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) - self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) - self.voltage = AttrR(Float()) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) async def _get_start(self) -> int: return await self._protocol.send_query("S", int) @@ -93,10 +88,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static13.py b/docs/snippets/static13.py index 9313a873a..420acd994 100644 --- a/docs/snippets/static13.py +++ b/docs/snippets/static13.py @@ -7,7 +7,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.methods import command, scan from fastcs.transports.epics import EpicsGUIOptions @@ -43,23 +42,19 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) self.enabled = AttrRW( - Enum(OnOffEnum), + OnOffEnum, getter=Polled(self._get_enabled, period=0.2), setter=self._set_enabled, ) - self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) - self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) - self.voltage = AttrR(Float()) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) async def _get_start(self) -> int: return await self._protocol.send_query("S", int) @@ -94,10 +89,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static14.py b/docs/snippets/static14.py index 8af326fbd..9e25a6418 100644 --- a/docs/snippets/static14.py +++ b/docs/snippets/static14.py @@ -7,7 +7,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import configure_logging, logger from fastcs.methods import command, scan @@ -47,23 +46,19 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) self.enabled = AttrRW( - Enum(OnOffEnum), + OnOffEnum, getter=Polled(self._get_enabled, period=0.2), setter=self._set_enabled, ) - self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) - self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) - self.voltage = AttrR(Float()) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) async def _get_start(self) -> int: return await self._protocol.send_query("S", int) @@ -98,10 +93,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py index 35a244115..ac1a1d0d9 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -7,7 +7,6 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller -from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import LogLevel, configure_logging, logger from fastcs.methods import command, scan @@ -55,23 +54,19 @@ def __init__(self, index: int, connection: IPConnection) -> None: super().__init__(f"Ramp{suffix}") self.start = AttrRW( - Int(), - getter=Polled(self._get_start, period=0.2), - setter=self._set_start, + int, getter=Polled(self._get_start, period=0.2), setter=self._set_start ) self.end = AttrRW( - Int(), - getter=Polled(self._get_end, period=0.2), - setter=self._set_end, + int, getter=Polled(self._get_end, period=0.2), setter=self._set_end ) self.enabled = AttrRW( - Enum(OnOffEnum), + OnOffEnum, getter=Polled(self._get_enabled, period=0.2), setter=self._set_enabled, ) - self.target = AttrR(Float(), getter=Polled(self._get_target, period=0.2)) - self.actual = AttrR(Float(), getter=Polled(self._get_actual, period=0.2)) - self.voltage = AttrR(Float()) + self.target = AttrR(float, getter=Polled(self._get_target, period=0.2)) + self.actual = AttrR(float, getter=Polled(self._get_actual, period=0.2)) + self.voltage = AttrR(float) async def _get_start(self) -> int: return await self._protocol.send_query("S", int, topic=self.start) @@ -106,10 +101,10 @@ def __init__(self, ramp_count: int, settings: IPConnectionSettings): super().__init__() - self.device_id = AttrR(String(), getter=Polled(self._get_device_id, period=0.2)) - self.power = AttrR(Float(), getter=Polled(self._get_power, period=0.2)) + self.device_id = AttrR(str, getter=Polled(self._get_device_id, period=0.2)) + self.power = AttrR(float, getter=Polled(self._get_power, period=0.2)) self.ramp_rate = AttrRW( - Float(), + float, getter=Polled(self._get_ramp_rate, period=0.2), setter=self._set_ramp_rate, ) diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md index aa447c13f..e90248e9f 100644 --- a/docs/tutorials/static-drivers.md +++ b/docs/tutorials/static-drivers.md @@ -80,7 +80,7 @@ doesn't have a connection. ::: In [1]: controller.device_id -Out[1]: AttrR(String()) +Out[1]: AttrR(name=device_id, dtype=str) In [2]: controller.device_id.readback diff --git a/src/fastcs/attributes/_infer_datatype.py b/src/fastcs/attributes/_infer_datatype.py index a601781d6..35365c5ad 100644 --- a/src/fastcs/attributes/_infer_datatype.py +++ b/src/fastcs/attributes/_infer_datatype.py @@ -1,19 +1,11 @@ from __future__ import annotations -import enum import inspect from collections.abc import Callable from typing import Any, get_args, get_origin from fastcs.attributes.update import Update -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String - -_DEFAULT_DATATYPES: dict[type, Callable[[], DataType]] = { - int: Int, - float: Float, - bool: Bool, - str: String, -} +from fastcs.datatypes import resolve_datatype def _unwrap_update_annotation(annotation: Any) -> Any: @@ -23,25 +15,32 @@ def _unwrap_update_annotation(annotation: Any) -> Any: return annotation -def _datatype_for_type(py_type: Any) -> DataType | None: - if py_type in _DEFAULT_DATATYPES: - return _DEFAULT_DATATYPES[py_type]() - if isinstance(py_type, type) and issubclass(py_type, enum.Enum): - return Enum(py_type) - return None +def _datatype_for_annotation(annotation: Any) -> Any | None: + """The annotation itself, if it is a datatype an attribute can hold. + + The datatype *is* the python type, so inference is just a check that the + annotation names one FastCS supports - including subscripted spellings + such as ``Array1D[np.int32]``. + """ + try: + resolve_datatype(annotation) + except TypeError: + return None + + return annotation -def infer_datatype_from_getter(getter: Callable) -> DataType | None: - """Infer a default ``DataType`` from a getter's return type annotation.""" +def infer_datatype_from_getter(getter: Callable) -> Any | None: + """Infer a datatype from a getter's return type annotation.""" signature = inspect.signature(getter, eval_str=True) annotation = signature.return_annotation if annotation is inspect.Signature.empty: return None - return _datatype_for_type(_unwrap_update_annotation(annotation)) + return _datatype_for_annotation(_unwrap_update_annotation(annotation)) -def infer_datatype_from_setter(setter: Callable) -> DataType | None: - """Infer a default ``DataType`` from a setter's value parameter annotation.""" +def infer_datatype_from_setter(setter: Callable) -> Any | None: + """Infer a datatype from a setter's value parameter annotation.""" signature = inspect.signature(setter, eval_str=True) parameters = list(signature.parameters.values()) if not parameters: @@ -49,4 +48,4 @@ def infer_datatype_from_setter(setter: Callable) -> DataType | None: annotation = parameters[0].annotation if annotation is inspect.Signature.empty: return None - return _datatype_for_type(annotation) + return _datatype_for_annotation(annotation) diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index 3e4243d5a..d0df6567f 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -3,13 +3,27 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import KW_ONLY, dataclass, replace -from typing import Any, Generic +from typing import Any, Generic, Unpack, overload from fastcs.attributes._infer_datatype import infer_datatype_from_getter from fastcs.attributes.attribute import Attribute, AttributeAccessMode from fastcs.attributes.update import Update from fastcs.attributes.util import AttrValuePredicate, PredicateEvent -from fastcs.datatypes import DataType, DType_T +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) from fastcs.logging import logger from fastcs.util import ONCE @@ -63,12 +77,95 @@ def __call__(self, getter: Getter[DType_T]) -> NotPolled[DType_T]: class AttrR(Attribute[DType_T]): """A read-only ``Attribute``""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrR(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrR(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload + def __init__( + self: AttrR[bool], + datatype: type[bool], + getter: Getter[bool] | Schedule[bool] | None = None, + initial_value: bool | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[int], + datatype: type[int], + getter: Getter[int] | Schedule[int] | None = None, + initial_value: int | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[float], + datatype: type[float], + getter: Getter[float] | Schedule[float] | None = None, + initial_value: float | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[str], + datatype: type[str], + getter: Getter[str] | Schedule[str] | None = None, + initial_value: str | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Enum_T], + datatype: type[Enum_T], + getter: Getter[Enum_T] | Schedule[Enum_T] | None = None, + initial_value: Enum_T | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Table], + datatype: type[Table], + getter: Getter[Table] | Schedule[Table] | None = None, + initial_value: Table | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Array_T], + datatype: type[Array_T], + getter: Getter[Array_T] | Schedule[Array_T] | None = None, + initial_value: Array_T | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrR[Inferred_T], + datatype: None = None, + getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None, + initial_value: Inferred_T | None = None, + **meta: Unpack[Meta], + ) -> None: ... + def __init__( self, - datatype: DataType[DType_T] | None = None, - getter: Getter[DType_T] | Schedule[DType_T] | None = None, - initial_value: DType_T | None = None, - **kwargs: Any, + datatype: Any = None, + getter: Any = None, + initial_value: Any = None, + **meta: Any, ) -> None: match getter: case Polled() | NotPolled(): @@ -90,12 +187,12 @@ def __init__( # Pass the datatype on rather than validating it here: in an ``AttrRW`` the # setter may still supply it, and ``Attribute`` makes the final check. - super().__init__(datatype, **kwargs) + super().__init__(datatype, **meta) self._value: DType_T = ( - self._datatype.initial_value if initial_value is None else initial_value + self.default_value() if initial_value is None else initial_value ) - self._getter = resolved_getter + self._getter: Getter[DType_T] | None = resolved_getter self._poll_period: float | None = poll_period """Period in seconds between calls to poll(), or ONCE, or None (on-demand)""" self._readback_callbacks: ( @@ -148,7 +245,7 @@ async def update(self, value: DType_T | Update[DType_T]) -> None: _previous_value = self._value try: - self._value = self._datatype.validate(value) + self._value = self.validate(value) except ValueError: logger.error("Failed to validate value", value=repr(value), attribute=self) raise @@ -163,7 +260,7 @@ async def update(self, value: DType_T | Update[DType_T]) -> None: callbacks_to_call: list[AttrReadbackCallback[DType_T]] = [ cb for cb, always in self._readback_callbacks - if always or not self.datatype.equal(self._value, _previous_value) + if always or not self.equal(self._value, _previous_value) ] try: await asyncio.gather(*[cb(self._value) for cb in callbacks_to_call]) diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py index 4214254e2..90222c781 100644 --- a/src/fastcs/attributes/attr_rw.py +++ b/src/fastcs/attributes/attr_rw.py @@ -1,35 +1,142 @@ from __future__ import annotations -from typing import Any +from typing import Any, Unpack, overload from fastcs.attributes.attr_r import AttrR, Getter, Schedule from fastcs.attributes.attr_w import AttrW, Setter from fastcs.attributes.attribute import AttributeAccessMode from fastcs.attributes.update import Update -from fastcs.datatypes import DataType, DType_T +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) from fastcs.logging import logger class AttrRW(AttrR[DType_T], AttrW[DType_T]): """A read-write ``Attribute``.""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrRW(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrRW(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload + def __init__( + self: AttrRW[bool], + datatype: type[bool], + getter: Getter[bool] | Schedule[bool] | None = None, + setter: Setter[bool] | None = None, + initial_value: bool | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[int], + datatype: type[int], + getter: Getter[int] | Schedule[int] | None = None, + setter: Setter[int] | None = None, + initial_value: int | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[float], + datatype: type[float], + getter: Getter[float] | Schedule[float] | None = None, + setter: Setter[float] | None = None, + initial_value: float | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[str], + datatype: type[str], + getter: Getter[str] | Schedule[str] | None = None, + setter: Setter[str] | None = None, + initial_value: str | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Enum_T], + datatype: type[Enum_T], + getter: Getter[Enum_T] | Schedule[Enum_T] | None = None, + setter: Setter[Enum_T] | None = None, + initial_value: Enum_T | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Table], + datatype: type[Table], + getter: Getter[Table] | Schedule[Table] | None = None, + setter: Setter[Table] | None = None, + initial_value: Table | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Array_T], + datatype: type[Array_T], + getter: Getter[Array_T] | Schedule[Array_T] | None = None, + setter: Setter[Array_T] | None = None, + initial_value: Array_T | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrRW[Inferred_T], + datatype: None = None, + getter: Getter[Inferred_T] | Schedule[Inferred_T] | None = None, + setter: Setter[Inferred_T] | None = None, + initial_value: Inferred_T | None = None, + **meta: Unpack[Meta], + ) -> None: ... + def __init__( self, - datatype: DataType[DType_T] | None = None, - getter: Getter[DType_T] | Schedule[DType_T] | None = None, - setter: Setter[DType_T] | None = None, - initial_value: DType_T | None = None, - **kwargs: Any, + datatype: Any = None, + getter: Any = None, + setter: Any = None, + initial_value: Any = None, + **meta: Any, ): # There is no datatype handling to do here. ``AttrR`` infers it from the # getter and ``AttrW`` from the setter; the MRO runs both in turn, so # whichever can resolve it does, and ``Attribute`` makes the final check. + # ``setter`` travels through ``AttrR`` to ``AttrW`` the same way, which + # the public overloads - describing what a caller may pass - do not show. super().__init__( datatype, getter=getter, - setter=setter, + setter=setter, # pyright: ignore[reportCallIssue] initial_value=initial_value, - **kwargs, + **meta, ) @property diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py index 696d66dc7..ca7e06c85 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -2,12 +2,26 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine -from typing import Any +from typing import Any, Unpack, overload from fastcs.attributes._infer_datatype import infer_datatype_from_setter from fastcs.attributes.attribute import Attribute, AttributeAccessMode from fastcs.attributes.update import Update -from fastcs.datatypes import DataType, DType_T +from fastcs.datatypes import ( + Array1DMeta, + Array_T, + BoolMeta, + DType_T, + Enum_T, + EnumMeta, + FloatMeta, + Inferred_T, + IntMeta, + Meta, + StrMeta, + Table, + TableMeta, +) from fastcs.logging import logger Setter = Callable[[DType_T], Awaitable[None | DType_T | Update[DType_T]]] @@ -19,19 +33,94 @@ class AttrW(Attribute[DType_T]): """A write-only ``Attribute``.""" + # One overload per datatype, so that metadata a datatype has no use for is + # a type error rather than a field silently ignored: ``AttrW(str, + # precision=3)`` does not type check. The last overload is the + # inferred-datatype case, where the datatype is only known from the + # getter/setter annotation, so the metadata is checked at runtime. + # + # Overload resolution takes the first datatype a call matches, and ``bool`` + # matches ``int`` while ``int`` matches ``float``. So ``AttrW(bool, + # units=...)`` resolves to the ``int`` overload rather than failing here - + # the constructor's runtime check is what rejects it. A call whose metadata + # is valid always picks its own datatype's overload. + @overload + def __init__( + self: AttrW[bool], + datatype: type[bool], + setter: Setter[bool] | None = None, + **meta: Unpack[BoolMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[int], + datatype: type[int], + setter: Setter[int] | None = None, + **meta: Unpack[IntMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[float], + datatype: type[float], + setter: Setter[float] | None = None, + **meta: Unpack[FloatMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[str], + datatype: type[str], + setter: Setter[str] | None = None, + **meta: Unpack[StrMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Enum_T], + datatype: type[Enum_T], + setter: Setter[Enum_T] | None = None, + **meta: Unpack[EnumMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Table], + datatype: type[Table], + setter: Setter[Table] | None = None, + **meta: Unpack[TableMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Array_T], + datatype: type[Array_T], + setter: Setter[Array_T] | None = None, + **meta: Unpack[Array1DMeta], + ) -> None: ... + + @overload + def __init__( + self: AttrW[Inferred_T], + datatype: None = None, + setter: Setter[Inferred_T] | None = None, + **meta: Unpack[Meta], + ) -> None: ... + def __init__( self, - datatype: DataType[DType_T] | None = None, - setter: Setter[DType_T] | None = None, - **kwargs: Any, + datatype: Any = None, + setter: Any = None, + **meta: Any, ) -> None: if datatype is None and setter is not None: datatype = infer_datatype_from_setter(setter) - super().__init__(datatype, **kwargs) + super().__init__(datatype, **meta) - self._setter = setter - self._setpoint: DType_T = self._datatype.initial_value + self._setter: Setter[DType_T] | None = setter + self._setpoint: DType_T = self.default_value() self._setpoint_known = False """Whether the setpoint reflects a real value rather than the datatype default @@ -69,7 +158,7 @@ async def update_setpoint(self, value: DType_T) -> None: This does no IO - it is the setpoint-side counterpart of ``AttrR.update``. """ - self._setpoint = self._datatype.validate(value) + self._setpoint = self.validate(value) self._setpoint_known = True if self._setpoint_callbacks: diff --git a/src/fastcs/attributes/attribute.py b/src/fastcs/attributes/attribute.py index f98e0ebab..3b54d8b30 100644 --- a/src/fastcs/attributes/attribute.py +++ b/src/fastcs/attributes/attribute.py @@ -1,8 +1,17 @@ from abc import ABC, abstractmethod from collections.abc import Callable -from typing import Generic, Literal - -from fastcs.datatypes import DataType, DType, DType_T +from typing import Any, Generic, Literal, cast + +from fastcs.datatypes import ( + DType_T, + Meta, + Table, + default_value, + resolve_datatype, + validate_meta, + validate_value, + values_equal, +) from fastcs.tracer import Tracer AttributeAccessMode = Literal["r", "w", "rw"] @@ -12,13 +21,17 @@ class Attribute(Generic[DType_T], Tracer, ABC): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the FastCS class. + + An attribute's datatype is a python type - ``float``, an `enum.Enum` + subclass, ``Array1D[np.int32]`` - and everything else that describes it + (precision, units, limits, shape) is metadata, held as a `Meta` typed dict + on ``attr.meta``. """ def __init__( self, - datatype: DataType[DType_T] | None = None, - group: str | None = None, - description: str | None = None, + datatype: Any = None, + **meta: Any, ) -> None: super().__init__() @@ -31,33 +44,39 @@ def __init__( "getter's return annotation or the setter's value annotation" ) - assert issubclass(datatype.dtype, DType), ( - f"Attr type must be one of {DType}, received type {datatype.dtype}" - ) - self._datatype: DataType[DType_T] = datatype - self._group = group + dtype, element_type = resolve_datatype(datatype) + self._meta: Meta = _resolve_meta(datatype, element_type, meta) + self._dtype: type[DType_T] = dtype # pyright: ignore[reportAttributeAccessIssue] + + validate_meta(dtype, self._meta) + self.enabled = True - self.description = description - # A callback to use when setting the datatype to a different value, for example - # changing the units on an int. - self._update_datatype_callbacks: list[Callable[[DataType[DType_T]], None]] = [] + # A callback to use when setting the metadata to a different value, for + # example changing the units on an int. + self._update_meta_callbacks: list[Callable[[Meta], None]] = [] # Path and name to be filled in by Controller it is bound to self._name = "" self._path = [] @property - def datatype(self) -> DataType[DType_T]: - return self._datatype + def dtype(self) -> type[DType_T]: + """The python type this attribute holds.""" + return self._dtype @property - def dtype(self) -> type[DType_T]: - return self._datatype.dtype + def meta(self) -> Meta: + """Everything known about this attribute beyond its python type.""" + return self._meta + + @property + def description(self) -> str | None: + return self._meta.get("description") @property def group(self) -> str | None: - return self._group + return self._meta.get("group") @property def name(self) -> str: @@ -77,19 +96,47 @@ def access_mode(self) -> AttributeAccessMode: """The access mode of this attribute.""" ... - def add_update_datatype_callback( - self, callback: Callable[[DataType[DType_T]], None] - ) -> None: - self._update_datatype_callbacks.append(callback) + def validate(self, value: Any) -> DType_T: + """Coerce a value to this attribute's datatype and check its metadata. - def update_datatype(self, datatype: DataType[DType_T]) -> None: - if not isinstance(self._datatype, type(datatype)): - raise ValueError( - f"Attribute datatype must be of type {type(self._datatype)}" - ) - self._datatype = datatype - for callback in self._update_datatype_callbacks: - callback(datatype) + Args: + value: The value to validate + + Returns: + The validated value + + Raises: + ValueError: If the value cannot be coerced, or breaks the metadata + + """ + return validate_value(self._dtype, self._meta, value) + + def equal(self, value1: DType_T, value2: DType_T) -> bool: + """Whether two values of this attribute's datatype are equal.""" + return values_equal(self._dtype, value1, value2) + + def default_value(self) -> DType_T: + """The value this attribute holds before anything has set one.""" + return default_value(self._dtype, self._meta) + + def add_update_meta_callback(self, callback: Callable[[Meta], None]) -> None: + self._update_meta_callbacks.append(callback) + + def update_meta(self, meta: Meta) -> None: + """Replace this attribute's metadata, notifying anything serving it. + + Args: + meta: The new metadata, which must be valid for the datatype + + Raises: + TypeError: If a field is not meaningful for the datatype + + """ + validate_meta(self._dtype, meta, self.full_name or "attribute") + + self._meta = meta + for callback in self._update_meta_callbacks: + callback(meta) def set_name(self, name: str): if self._name: @@ -110,6 +157,38 @@ def set_path(self, path: list[str]): def __repr__(self): name = self.__class__.__name__ full_name = self.full_name or None - datatype = self._datatype.__class__.__name__ - return f"{name}(name={full_name}, datatype={datatype})" + return f"{name}(name={full_name}, dtype={self._dtype.__name__})" + + +def _resolve_meta( + datatype: Any, + element_type: Any, + meta: dict[str, Any], +) -> Meta: + """Fold what the datatype spelling implied into the metadata given.""" + resolved: dict[str, Any] = {k: v for k, v in meta.items() if v is not None} + + spelled_as_table = isinstance(datatype, type) and issubclass(datatype, Table) + if spelled_as_table and "structured_dtype" not in resolved: + raise TypeError( + "A Table attribute needs its columns - pass " + "structured_dtype=[('name', np.int32), ...]" + ) + if not spelled_as_table and "structured_dtype" in resolved: + raise TypeError( + "structured_dtype is only valid for a Table attribute; declare the " + "datatype as Table to use it" + ) + + if element_type is not None: + # ``Array1D[np.int32]`` says the element type; an explicit array_dtype + # would be a second, possibly contradictory, source for it. + if "array_dtype" in resolved: + raise TypeError( + "The element type is already given by the datatype subscript; " + "drop the array_dtype argument" + ) + resolved["array_dtype"] = element_type + + return cast(Meta, resolved) diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index 725b02e53..05f207c2e 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -283,12 +283,12 @@ def add_attribute(self, name, attr: Attribute): f"hinted attribute '{name}' does not match defined access mode. " f"Expected '{hint.attr_type.__name__}' got '{type(attr).__name__}'." ) - if hint.dtype is not None and hint.dtype != attr.datatype.dtype: + if hint.dtype is not None and hint.dtype != attr.dtype: raise RuntimeError( f"Controller '{self.__class__.__name__}' introspection of " f"hinted attribute '{name}' does not match defined datatype. " f"Expected '{hint.dtype.__name__}', " - f"got '{attr.datatype.dtype.__name__}'." + f"got '{attr.dtype.__name__}'." ) attr.set_name(name) diff --git a/src/fastcs/datatypes/__init__.py b/src/fastcs/datatypes/__init__.py index fc108c9d5..8cbe51bde 100644 --- a/src/fastcs/datatypes/__init__.py +++ b/src/fastcs/datatypes/__init__.py @@ -1,11 +1,28 @@ -from ._util import numpy_to_fastcs_datatype as numpy_to_fastcs_datatype -from .bool import Bool as Bool -from .datatype import DataType as DataType -from .datatype import DType as DType -from .datatype import DType_T as DType_T -from .enum import Enum as Enum -from .float import Float as Float -from .int import Int as Int -from .string import String as String -from .table import Table as Table -from .waveform import Waveform as Waveform +from ._util import numpy_to_python_type as numpy_to_python_type +from .limits import Limits as Limits +from .limits import NumericLimits as NumericLimits +from .meta import DEFAULT_ARRAY_SHAPE as DEFAULT_ARRAY_SHAPE +from .meta import DEFAULT_PRECISION as DEFAULT_PRECISION +from .meta import Array1DMeta as Array1DMeta +from .meta import BoolMeta as BoolMeta +from .meta import CommonMeta as CommonMeta +from .meta import EnumMeta as EnumMeta +from .meta import FloatMeta as FloatMeta +from .meta import IntMeta as IntMeta +from .meta import Meta as Meta +from .meta import StrMeta as StrMeta +from .meta import TableMeta as TableMeta +from .types import Array1D as Array1D +from .types import Array_T as Array_T +from .types import DType as DType +from .types import DType_T as DType_T +from .types import Enum_T as Enum_T +from .types import Inferred_T as Inferred_T +from .types import Table as Table +from .types import is_array_datatype as is_array_datatype +from .types import resolve_datatype as resolve_datatype +from .validation import array_dtype_of as array_dtype_of +from .validation import default_value as default_value +from .validation import validate_meta as validate_meta +from .validation import validate_value as validate_value +from .validation import values_equal as values_equal diff --git a/src/fastcs/datatypes/_numeric.py b/src/fastcs/datatypes/_numeric.py deleted file mode 100644 index 8b8f44da8..000000000 --- a/src/fastcs/datatypes/_numeric.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass -from typing import Any, TypeVar - -from fastcs.datatypes.datatype import DataType - -Numeric_T = TypeVar("Numeric_T", int, float) -"""A numeric type supported by a corresponding FastCS Attribute DataType""" - - -@dataclass(frozen=True) -class _Numeric(DataType[Numeric_T]): - """Base class for numeric FastCS DataType classes""" - - units: str | None = None - """The units of the numeric value""" - min: Numeric_T | None = None - """The minimum allowed value - values below this will raise an exception""" - max: Numeric_T | None = None - """The maximum allowed value - values above this will raise an exception""" - min_alarm: Numeric_T | None = None - """The minimum alarm limit - values below this will be set with an alarm state""" - max_alarm: Numeric_T | None = None - """The maximum alarm limit - values above this will be set with an alarm state""" - - def validate(self, value: Any) -> Numeric_T: - _value = super().validate(value) - - if self.min is not None and _value < self.min: - raise ValueError(f"Value {_value} is less than minimum {self.min}") - - if self.max is not None and _value > self.max: - raise ValueError(f"Value {_value} is greater than maximum {self.max}") - - return _value - - @property - def initial_value(self) -> Numeric_T: - return self.dtype(0) diff --git a/src/fastcs/datatypes/_util.py b/src/fastcs/datatypes/_util.py index b590f7ff8..bd55ac958 100644 --- a/src/fastcs/datatypes/_util.py +++ b/src/fastcs/datatypes/_util.py @@ -1,21 +1,18 @@ import numpy as np -from fastcs.datatypes.bool import Bool -from fastcs.datatypes.datatype import DataType -from fastcs.datatypes.float import Float -from fastcs.datatypes.int import Int -from fastcs.datatypes.string import String +from fastcs.datatypes.types import DType -def numpy_to_fastcs_datatype(np_type) -> DataType: - """Converts numpy types to fastcs types for widget creation. - Only types important for widget creation are explicitly converted +def numpy_to_python_type(np_type) -> type[DType]: + """Converts numpy types to python types for widget creation. + + Only types important for widget creation are explicitly converted. """ if np.issubdtype(np_type, np.integer): - return Int() + return int elif np.issubdtype(np_type, np.floating): - return Float() + return float elif np.issubdtype(np_type, np.bool_): - return Bool() + return bool else: - return String() + return str diff --git a/src/fastcs/datatypes/bool.py b/src/fastcs/datatypes/bool.py deleted file mode 100644 index 7b99ae2a9..000000000 --- a/src/fastcs/datatypes/bool.py +++ /dev/null @@ -1,16 +0,0 @@ -from dataclasses import dataclass - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Bool(DataType[bool]): - """`DataType` mapping to builtin ``bool``.""" - - @property - def dtype(self) -> type[bool]: - return bool - - @property - def initial_value(self) -> bool: - return False diff --git a/src/fastcs/datatypes/datatype.py b/src/fastcs/datatypes/datatype.py deleted file mode 100644 index 09953bd7d..000000000 --- a/src/fastcs/datatypes/datatype.py +++ /dev/null @@ -1,94 +0,0 @@ -import enum -from abc import abstractmethod -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, Generic, TypeVar - -import numpy as np - -DType = ( - int # Int - | float # Float - | bool # Bool - | str # String - | enum.Enum # Enum - | np.ndarray # Waveform / Table -) -"""A builtin (or numpy) type supported by a corresponding FastCS Attribute DataType""" - -DType_T = TypeVar("DType_T", bound=DType) -"""A TypeVar of `DType` for use in generic classes and functions""" - - -@dataclass(frozen=True) -class DataType(Generic[DType_T]): - """Generic datatype mapping to a python type, with additional metadata.""" - - @property - @abstractmethod - def dtype(self) -> type[DType_T]: # Using property due to lack of Generic ClassVars - """Underlying python type""" - raise NotImplementedError() - - @property - @abstractmethod - def initial_value(self) -> DType_T: - """Fallback initial value if not specified in `Attribute`""" - raise NotImplementedError() - - def validate(self, value: Any) -> DType_T: - """Validate a value against the datatype. - - The base implementation is to try the cast and raise a useful error if it fails. - - Child classes can implement logic before calling ``super.validate(value)`` to - modify the value passed in and help the cast succeed or after to perform further - validation of the coerced type. - - Args: - value: The value to validate - - Returns: - The validated value - - Raises: - ValueError: If the value cannot be coerced - - """ - if isinstance(value, self.dtype): - return value - - try: - return self.dtype(value) - except (ValueError, TypeError) as e: - raise ValueError(f"Failed to cast {value} to type {self.dtype}") from e - - @staticmethod - def equal(value1: DType_T, value2: DType_T) -> bool: - """Compare two values for equality - - Child classes can override this if the underlying type does not implement - ``__eq__`` or to define custom logic. - - Args: - value1: The first value to compare - value2: The second value to compare - - Returns: - `True` if the values are equal - - """ - return value1 == value2 - - @classmethod - def all_equal(cls, values: Sequence[DType_T]) -> bool: - """Compare a sequence of values for equality - - Args: - values: Values to compare - - Returns: - `True` if all values are equal, else `False` - - """ - return all(cls.equal(values[0], value) for value in values[1:]) diff --git a/src/fastcs/datatypes/enum.py b/src/fastcs/datatypes/enum.py deleted file mode 100644 index e490f5c76..000000000 --- a/src/fastcs/datatypes/enum.py +++ /dev/null @@ -1,37 +0,0 @@ -import enum -from dataclasses import dataclass -from functools import cached_property -from typing import Generic, TypeVar - -from fastcs.datatypes.datatype import DataType - -Enum_T = TypeVar("Enum_T", bound=enum.Enum) -"""A builtin Enum type""" - - -@dataclass(frozen=True) -class Enum(Generic[Enum_T], DataType[Enum_T]): - enum_cls: type[Enum_T] - - def __post_init__(self): - if not issubclass(self.enum_cls, enum.Enum): - raise ValueError("Enum class has to take an Enum.") - - def index_of(self, value: Enum_T) -> int: - return self.members.index(value) - - @cached_property - def members(self) -> list[Enum_T]: - return list(self.enum_cls) - - @cached_property - def names(self) -> list[str]: - return [member.name for member in self.members] - - @property - def dtype(self) -> type[Enum_T]: - return self.enum_cls - - @property - def initial_value(self) -> Enum_T: - return self.members[0] diff --git a/src/fastcs/datatypes/float.py b/src/fastcs/datatypes/float.py deleted file mode 100644 index 6e24f384f..000000000 --- a/src/fastcs/datatypes/float.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fastcs.datatypes._numeric import _Numeric - - -@dataclass(frozen=True) -class Float(_Numeric[float]): - """`DataType` mapping to builtin ``float``.""" - - prec: int = 2 - """Number of decimal places to represent value""" - - @property - def dtype(self) -> type[float]: - return float - - def validate(self, value: Any) -> float: - _value = super().validate(value) - - if self.prec is not None: - _value = round(_value, self.prec) - - return _value diff --git a/src/fastcs/datatypes/int.py b/src/fastcs/datatypes/int.py deleted file mode 100644 index 31858e97e..000000000 --- a/src/fastcs/datatypes/int.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass - -from fastcs.datatypes._numeric import _Numeric - - -@dataclass(frozen=True) -class Int(_Numeric[int]): - """`DataType` mapping to builtin ``int``.""" - - @property - def dtype(self) -> type[int]: - return int diff --git a/src/fastcs/datatypes/limits.py b/src/fastcs/datatypes/limits.py new file mode 100644 index 000000000..635384ad5 --- /dev/null +++ b/src/fastcs/datatypes/limits.py @@ -0,0 +1,83 @@ +"""Numeric limits, aligned with the bluesky event-model (ADR 0017).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypeVar + +Numeric_T = TypeVar("Numeric_T", int, float) +"""A numeric type that can carry limits""" + + +@dataclass(frozen=True) +class Limits(Generic[Numeric_T]): + """A pair of bounds on a numeric value. + + Either end may be ``None``, meaning unbounded in that direction. + """ + + low: Numeric_T | None = None + """The lower bound, or ``None`` for unbounded""" + high: Numeric_T | None = None + """The upper bound, or ``None`` for unbounded""" + + def contains(self, other: Limits[Numeric_T]) -> bool: + """Whether ``other`` lies within this range. + + An unbounded end of ``self`` contains any value; an unbounded end of + ``other`` is only contained by an unbounded end of ``self``. + """ + if self.low is not None and (other.low is None or other.low < self.low): + return False + if self.high is not None and (other.high is None or other.high > self.high): + return False + + return True + + +UNBOUNDED: Limits = Limits() +"""Limits with neither end set""" + + +@dataclass(frozen=True) +class NumericLimits(Generic[Numeric_T]): + """The four categories of limit on a numeric attribute. + + All four are optional and are resolved on construction, so that after + construction every category holds a `Limits` - unbounded if nothing + determined it. The rules (ADR 0017) are: + + - supply none and all four are unbounded; + - a ``display`` range with no ``control`` range gives ``control`` the + display range - what a device may be driven to defaults to what it is + shown as spanning; + - an ``alarm`` range with no ``warning`` range gives ``warning`` the alarm + range; + - supplying both asserts that ``warning`` lies within ``alarm``, since a + warning outside the alarm range could never be the milder condition. + + >>> limits = NumericLimits(display=Limits(0.0, 10.0)) + >>> limits.control + Limits(low=0.0, high=10.0) + """ + + control: Limits[Numeric_T] = UNBOUNDED + """The range the attribute may be driven to""" + display: Limits[Numeric_T] = UNBOUNDED + """The range the attribute is displayed over""" + alarm: Limits[Numeric_T] = UNBOUNDED + """The range outside which the attribute is in alarm""" + warning: Limits[Numeric_T] = UNBOUNDED + """The range outside which the attribute is in a warning state""" + + def __post_init__(self) -> None: + if self.control == UNBOUNDED and self.display != UNBOUNDED: + object.__setattr__(self, "control", self.display) + + if self.warning == UNBOUNDED and self.alarm != UNBOUNDED: + object.__setattr__(self, "warning", self.alarm) + elif self.alarm != UNBOUNDED and not self.alarm.contains(self.warning): + raise ValueError( + f"Warning limits {self.warning} are not within alarm limits " + f"{self.alarm}" + ) diff --git a/src/fastcs/datatypes/meta.py b/src/fastcs/datatypes/meta.py new file mode 100644 index 000000000..4dd8a2f7a --- /dev/null +++ b/src/fastcs/datatypes/meta.py @@ -0,0 +1,115 @@ +"""Per-datatype metadata, as typed dicts (ADR 0014). + +Each python type an `Attribute` can hold has a ``*Meta`` typed dict saying what +metadata is meaningful for it - ``precision`` for a ``float``, ``length`` for a +``str``, ``shape`` for an array. The ``Attr*`` constructors unpack the right one +for the datatype they were given, so ``AttrRW(str, precision=3)`` is a static +type error rather than a field silently ignored at runtime. + +`Meta` is the superset of every field, all optional. It is what a declarative +extras object (such as the demo's ``SCPIParam``) takes, since an +``Annotated[...]`` extra cannot tie its metadata to the attribute's datatype +statically - the ``ControllerFiller`` validates those at fill time instead. +""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from numpy.typing import DTypeLike + +from fastcs.datatypes.limits import NumericLimits + +DEFAULT_PRECISION = 2 +"""Decimal places a ``float`` attribute is rounded to when unspecified""" + +DEFAULT_ARRAY_SHAPE: tuple[int, ...] = (2000,) +"""Maximum shape of an array attribute when unspecified""" + + +class CommonMeta(TypedDict, total=False): + """Metadata meaningful for an attribute of any datatype.""" + + description: str + """Human readable description of what the attribute is""" + group: str + """Name of the group to display the attribute under""" + + +class BoolMeta(CommonMeta, total=False): + """Metadata for a ``bool`` attribute.""" + + +class IntMeta(CommonMeta, total=False): + """Metadata for an ``int`` attribute.""" + + units: str + """The units of the value""" + limits: NumericLimits[int] + """The control, display, alarm and warning ranges of the value""" + + +class FloatMeta(CommonMeta, total=False): + """Metadata for a ``float`` attribute.""" + + units: str + """The units of the value""" + limits: NumericLimits[float] + """The control, display, alarm and warning ranges of the value""" + precision: int + """Number of decimal places to round to and display""" + + +class StrMeta(CommonMeta, total=False): + """Metadata for a ``str`` attribute.""" + + length: int + """Maximum length of the string. Must be >= 1""" + + +class EnumMeta(CommonMeta, total=False): + """Metadata for an `enum.Enum` attribute. + + Display only - the choices come from the enum class itself. + """ + + +class Array1DMeta(CommonMeta, total=False): + """Metadata for an `Array1D` attribute.""" + + array_dtype: DTypeLike + """Numpy element type, if not given by the datatype subscript""" + shape: tuple[int, ...] + """Maximum shape of the array""" + + +class TableMeta(CommonMeta, total=False): + """Metadata for a `Table` attribute.""" + + structured_dtype: list[tuple[str, DTypeLike]] + """The columns of the table, as a numpy structured dtype""" + + +class Meta(CommonMeta, total=False): + """Every metadata field, all optional. + + The spelling for metadata that cannot be tied to a datatype statically - + a declarative extras object collecting whatever the protocol layer was + told, validated against the datatype when the attribute is built. + + Spelled out rather than inheriting every ``*Meta``, because ``IntMeta`` and + ``FloatMeta`` disagree on the type of ``limits``. + """ + + units: str + limits: NumericLimits[int] | NumericLimits[float] + precision: int + length: int + array_dtype: DTypeLike + shape: tuple[int, ...] + structured_dtype: list[tuple[str, DTypeLike]] + + +def meta_fields(meta_cls: Any) -> frozenset[str]: + """The field names of a ``*Meta`` typed dict, including inherited ones.""" + return frozenset(meta_cls.__optional_keys__) | frozenset(meta_cls.__required_keys__) diff --git a/src/fastcs/datatypes/string.py b/src/fastcs/datatypes/string.py deleted file mode 100644 index e4deb15bb..000000000 --- a/src/fastcs/datatypes/string.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class String(DataType[str]): - """`DataType` mapping to builtin ``str``.""" - - length: int | None = None - """Maximum length of string to display in transports. Must be >=1 or None.""" - - def __post_init__(self): - if self.length is not None and self.length < 1: - raise ValueError("String length must be >= 1") - - @property - def dtype(self) -> type[str]: - return str - - @property - def initial_value(self) -> str: - return "" - - def validate(self, value: Any) -> str: - """Truncate string to maximum length - - Returns: - The string, truncated to the maximum length if set - - """ - return super().validate(value)[: self.length] diff --git a/src/fastcs/datatypes/table.py b/src/fastcs/datatypes/table.py deleted file mode 100644 index f8f3a6d60..000000000 --- a/src/fastcs/datatypes/table.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -import numpy as np -from numpy.typing import DTypeLike - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Table(DataType[np.ndarray]): - structured_dtype: list[tuple[str, DTypeLike]] - """The structured dtype for numpy array - - See docs for more information: - https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation - """ - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.array([], dtype=self.structured_dtype) - - def validate(self, value: Any) -> np.ndarray: - _value = super().validate(value) - - if self.structured_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype.descr} is not the same as the structured " - f"dtype {self.structured_dtype}" - ) - - return _value - - @staticmethod - def equal(value1: np.ndarray, value2: np.ndarray) -> bool: - return np.array_equal(value1, value2) diff --git a/src/fastcs/datatypes/types.py b/src/fastcs/datatypes/types.py new file mode 100644 index 000000000..bea3b7bb7 --- /dev/null +++ b/src/fastcs/datatypes/types.py @@ -0,0 +1,144 @@ +"""The python types a FastCS `Attribute` can hold, and how they are spelled. + +There is no ``DataType`` object: an attribute's datatype *is* a python type, +and everything that used to hang off a ``DataType`` instance - precision, +units, limits, array shape - now travels separately as a ``*Meta`` typed dict +(see :py:mod:`fastcs.datatypes.meta`). +""" + +from __future__ import annotations + +import enum +from typing import Any, TypeAlias, TypeVar, get_args, get_origin + +import numpy as np +from numpy.typing import DTypeLike + +DType = ( + int # int + | float # float + | bool # bool + | str # str + | enum.Enum # any Enum subclass + | np.ndarray # Array1D / Table +) +"""A python type that a FastCS `Attribute` can hold""" + +DType_T = TypeVar("DType_T", bound=DType) +"""A TypeVar of `DType` for use in generic classes and functions""" + +NumpyScalar_T = TypeVar("NumpyScalar_T", bound=np.generic, covariant=True) +"""The element type of a numpy array""" + +Enum_T = TypeVar("Enum_T", bound=enum.Enum) +"""A TypeVar of any `enum.Enum` subclass an attribute can hold""" + +Array_T = TypeVar("Array_T", bound=np.ndarray) +"""A TypeVar of any numpy array an attribute can hold""" + +Inferred_T = TypeVar("Inferred_T", bound=DType) +"""A TypeVar of `DType` for the constructor overload that infers the datatype + +Distinct from `DType_T` because the overload binds it from the getter or setter +in the same signature that annotates ``self``, and a class-scoped TypeVar cannot +be used there. +""" + +Array1D: TypeAlias = np.ndarray[tuple[int], np.dtype[NumpyScalar_T]] +"""A one dimensional numpy array, subscripted with its element type. + +``Array1D[np.int32]`` is both the type hint for an array attribute and the +datatype passed to its constructor - the element type is read straight off the +subscript, so it does not have to be repeated in the metadata:: + + AttrR(Array1D[np.int32], shape=(10,)) + +Neither the subscript nor the ``shape`` has to be repeated in the metadata, and +``shape`` may be left off entirely to take the default. numpy's own +``npt.NDArray[np.int32]`` is the same spelling with an unbounded shape and is +accepted wherever `Array1D` is:: + + AttrR(npt.NDArray[np.int32]) + +Arrays of higher rank have no ophyd-async-compatible spelling; write them as +``np.ndarray`` with an explicit ``array_dtype``:: + + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10)) +""" + + +class Table(np.ndarray): + """A structured ("record") numpy array, one field per column. + + Both the type hint and the datatype for a table attribute; the columns are + given as the ``structured_dtype`` metadata:: + + AttrR(Table, structured_dtype=[("index", np.int32), ("value", np.float64)]) + + See https://numpy.org/devdocs/user/basics.rec.html for structured dtypes. + """ + + +_BUILTIN_DTYPES: tuple[type, ...] = (bool, int, float, str) +"""The builtin types an attribute may hold, matched exactly rather than by +subclass - ``bool`` is a subclass of ``int``, and the two are not +interchangeable to a transport.""" + + +def is_array_datatype(dtype: type[DType]) -> bool: + """Whether ``dtype`` is held as a numpy array - an `Array1D` or a `Table`.""" + return issubclass(dtype, np.ndarray) + + +def resolve_datatype(datatype: Any) -> tuple[type[DType], DTypeLike | None]: + """Resolve a datatype as written into the python type an attribute holds. + + Args: + datatype: A datatype spelling - a builtin type, an `enum.Enum` + subclass, `Table`, ``np.ndarray``, or a subscripted `Array1D` + + Returns: + The python type, and the numpy element type carried by the spelling if + it had one (``Array1D[np.int32]`` carries ``np.int32``; a bare + ``np.ndarray`` carries nothing and needs an ``array_dtype``) + + Raises: + TypeError: If ``datatype`` is not a supported spelling + + """ + # ``Array1D[np.int32]`` is a subscripted generic alias rather than a class. + if (origin := get_origin(datatype)) is not None: + if not (isinstance(origin, type) and issubclass(origin, np.ndarray)): + raise TypeError(f"Unsupported datatype {datatype!r}") + + return np.ndarray, _element_type_of(datatype) + + if not isinstance(datatype, type): + raise TypeError( + f"Datatype must be a type, got {datatype!r}. Metadata such as " + "precision or units is passed as keyword arguments, not as part " + "of the datatype." + ) + + if datatype in _BUILTIN_DTYPES or issubclass(datatype, enum.Enum): + return datatype, None + + if issubclass(datatype, np.ndarray): + # ``Table`` and ``Array1D`` are both held as plain ``np.ndarray``; what + # separates them is whether the metadata gives a structured dtype. + return np.ndarray, None + + raise TypeError(f"Unsupported datatype {datatype!r}") + + +def _element_type_of(alias: Any) -> DTypeLike | None: + """The numpy element type of a subscripted ``np.ndarray`` alias, if given.""" + args = get_args(alias) + if len(args) != 2: + return None + + # ``np.ndarray[tuple[int], np.dtype[np.int32]]`` - the element type is the + # argument of the inner ``np.dtype``. + dtype_args = get_args(args[1]) + + return dtype_args[0] if dtype_args else None diff --git a/src/fastcs/datatypes/validation.py b/src/fastcs/datatypes/validation.py new file mode 100644 index 000000000..49cad726a --- /dev/null +++ b/src/fastcs/datatypes/validation.py @@ -0,0 +1,220 @@ +"""Validating and comparing attribute values against a datatype and its metadata. + +This is what the ``DataType`` classes used to do in ``validate``/``equal``/ +``initial_value``; with the datatype reduced to a python type, the behaviour +that depended on the metadata is dispatched here instead. +""" + +from __future__ import annotations + +import enum +from typing import Any, cast + +import numpy as np + +from fastcs.datatypes.limits import NumericLimits +from fastcs.datatypes.meta import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + Array1DMeta, + BoolMeta, + EnumMeta, + FloatMeta, + IntMeta, + Meta, + StrMeta, + TableMeta, + meta_fields, +) +from fastcs.datatypes.types import DType, DType_T + +_META_FOR_DTYPE: dict[type, Any] = { + bool: BoolMeta, + int: IntMeta, + float: FloatMeta, + str: StrMeta, +} + + +def meta_class_for(dtype: type[DType], meta: Meta) -> Any: + """The ``*Meta`` typed dict that applies to ``dtype``.""" + if dtype in _META_FOR_DTYPE: + return _META_FOR_DTYPE[dtype] + if issubclass(dtype, enum.Enum): + return EnumMeta + if issubclass(dtype, np.ndarray): + return TableMeta if "structured_dtype" in meta else Array1DMeta + + raise TypeError(f"Unsupported datatype {dtype!r}") + + +def validate_meta(dtype: type[DType], meta: Meta, name: str = "attribute") -> None: + """Check that every field of ``meta`` is meaningful for ``dtype``. + + The runtime counterpart of the ``Unpack[*Meta]`` overloads on the + constructors, for metadata that arrived without a static check - from a + ``ControllerFiller`` extras object, say. + + Args: + dtype: The python type the attribute holds + meta: The metadata to check + name: The attribute's name, to name it in the error + + Raises: + TypeError: If a field is not meaningful for the datatype + + """ + allowed = meta_fields(meta_class_for(dtype, meta)) + for field in meta: + if field not in allowed: + raise TypeError( + f"'{field}' is not valid metadata for {dtype.__name__} " + f"{name} - valid fields are {', '.join(sorted(allowed))}" + ) + + length = meta.get("length") + if length is not None and length < 1: + raise ValueError(f"String length must be >= 1, got {length} for {name}") + + +def array_dtype_of(meta: Meta, element_type: Any = None) -> Any: + """The numpy element type of an array attribute. + + Args: + meta: The attribute's metadata + element_type: The element type carried by the datatype spelling, if any + + Returns: + The numpy element type + + Raises: + TypeError: If neither source gives one + + """ + array_dtype = meta.get("array_dtype", element_type) + if array_dtype is None: + raise TypeError( + "An array attribute needs an element type - subscript the datatype " + "as Array1D[np.int32], or pass array_dtype=np.int32" + ) + + return array_dtype + + +def default_value(dtype: type[DType_T], meta: Meta) -> DType_T: + """The value an attribute holds before anything has set one.""" + if dtype is str: + return cast(DType_T, "") + if dtype is bool: + return cast(DType_T, False) + if dtype in (int, float): + return cast(DType_T, dtype(0)) + if issubclass(dtype, enum.Enum): + return cast(DType_T, next(iter(dtype))) + if issubclass(dtype, np.ndarray): + if (structured_dtype := meta.get("structured_dtype")) is not None: + return cast(DType_T, np.array([], dtype=structured_dtype)) + + return cast( + DType_T, + np.zeros( + meta.get("shape", DEFAULT_ARRAY_SHAPE), + dtype=array_dtype_of(meta), + ), + ) + + raise TypeError(f"Unsupported datatype {dtype!r}") + + +def values_equal(dtype: type[DType], value1: Any, value2: Any) -> bool: + """Whether two values of ``dtype`` are equal. + + Numpy arrays need ``array_equal`` rather than ``==``, which is elementwise. + """ + if issubclass(dtype, np.ndarray): + return bool(np.array_equal(value1, value2)) + + return bool(value1 == value2) + + +def validate_value(dtype: type[DType_T], meta: Meta, value: Any) -> DType_T: + """Coerce a value to ``dtype`` and check it against ``meta``. + + Args: + dtype: The python type the attribute holds + meta: The attribute's metadata + value: The value to validate + + Returns: + The validated value + + Raises: + ValueError: If the value cannot be coerced, or breaks the metadata + + """ + if issubclass(dtype, np.ndarray): + return cast(DType_T, _validate_array(meta, value)) + + coerced = _coerce(dtype, value) + + if dtype is float: + precision = meta.get("precision", DEFAULT_PRECISION) + coerced = cast(DType_T, round(cast(float, coerced), precision)) + elif dtype is str: + return cast(DType_T, cast(str, coerced)[: meta.get("length")]) + + if dtype in (int, float): + _check_limits(cast(int | float, coerced), meta.get("limits")) + + return coerced + + +def _coerce(dtype: type[DType_T], value: Any) -> DType_T: + if isinstance(value, dtype): + return value + + try: + return dtype(value) # pyright: ignore[reportCallIssue] + except (ValueError, TypeError) as e: + raise ValueError(f"Failed to cast {value} to type {dtype}") from e + + +def _check_limits(value: int | float, limits: NumericLimits | None) -> None: + if limits is None: + return + + control = limits.control + if control.low is not None and value < control.low: + raise ValueError(f"Value {value} is less than minimum {control.low}") + if control.high is not None and value > control.high: + raise ValueError(f"Value {value} is greater than maximum {control.high}") + + +def _validate_array(meta: Meta, value: Any) -> np.ndarray: + if (structured_dtype := meta.get("structured_dtype")) is not None: + array = np.asarray(value) + if structured_dtype != array.dtype: + raise ValueError( + f"Value dtype {array.dtype.descr} is not the same as the " + f"structured dtype {structured_dtype}" + ) + + return array + + array_dtype = array_dtype_of(meta) + array = np.asarray(value).astype(array_dtype) + if array_dtype != array.dtype: + raise ValueError( + f"Value dtype {array.dtype} is not the same as the array dtype " + f"{array_dtype}" + ) + + shape = meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) != len(array.shape) or any( + actual > maximum for actual, maximum in zip(array.shape, shape, strict=True) + ): + raise ValueError( + f"Value shape {array.shape} exceeeds the shape maximum shape {shape}" + ) + + return array diff --git a/src/fastcs/datatypes/waveform.py b/src/fastcs/datatypes/waveform.py deleted file mode 100644 index 8c09ce239..000000000 --- a/src/fastcs/datatypes/waveform.py +++ /dev/null @@ -1,46 +0,0 @@ -from dataclasses import dataclass - -import numpy as np -from numpy.typing import DTypeLike - -from fastcs.datatypes.datatype import DataType - - -@dataclass(frozen=True) -class Waveform(DataType[np.ndarray]): - array_dtype: DTypeLike - """Numpy array dtype""" - shape: tuple[int, ...] = (2000,) - """Numpy array shape""" - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.zeros(self.shape, dtype=self.array_dtype) - - def validate(self, value: np.ndarray) -> np.ndarray: - _value = super().validate(np.asarray(value).astype(self.array_dtype)) - - if self.array_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype} is not the same as the array dtype " - f"{self.array_dtype}" - ) - - if len(self.shape) != len(_value.shape) or any( - shape1 > shape2 - for shape1, shape2 in zip(_value.shape, self.shape, strict=True) - ): - raise ValueError( - f"Value shape {_value.shape} exceeeds the shape maximum shape " - f"{self.shape}" - ) - - return _value - - @staticmethod - def equal(value1: np.ndarray, value2: np.ndarray) -> bool: - return np.array_equal(value1, value2) diff --git a/src/fastcs/demo/eiger.py b/src/fastcs/demo/eiger.py index 4d473b9af..ba89d1f13 100644 --- a/src/fastcs/demo/eiger.py +++ b/src/fastcs/demo/eiger.py @@ -17,37 +17,36 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.controllers import Controller -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String +from fastcs.datatypes import DType from fastcs.demo.simulation.eiger import API_PREFIX, Subsystem, ValueType -_DATATYPES: dict[ValueType, type[DataType]] = { - "float": Float, - "int": Int, - "string": String, - "bool": Bool, +_DATATYPES: dict[ValueType, type[DType]] = { + "float": float, + "int": int, + "string": str, + "bool": bool, } # Poll period (seconds) for read-only status params that change on the device. UPDATE_PERIOD = 0.2 -def _datatype(param: str, data: dict[str, Any]) -> DataType: +def _datatype(param: str, data: dict[str, Any]) -> type[DType]: """Build a datatype for a parameter from the metadata the device reports. - A parameter that reports ``allowed_values`` is discrete, so it becomes an `Enum` - over an enum class built from those values. The members are only knowable over the - wire, which is exactly the case introspection exists for. + A parameter that reports ``allowed_values`` is discrete, so it becomes an enum + class built from those values. The members are only knowable over the wire, + which is exactly the case introspection exists for. """ allowed_values = data.get("allowed_values") if allowed_values is None: - return _DATATYPES[data["value_type"]]() + return _DATATYPES[data["value_type"]] name = "".join(part.title() for part in param.split("_")) # The functional API builds a class; type checkers only see the instance signature. - enum_cls = cast( + return cast( type[enum.Enum], enum.Enum(name, {value: value for value in allowed_values}) ) - return Enum(enum_cls) @dataclass @@ -112,7 +111,7 @@ class EigerDetector(Controller): # Derived (soft): built on top of the introspected ``state`` param. Declaring # ``state`` as a checked attribute is what lets us reference it in code and # publish something computed from it - here, whether the detector is idle. - idle = AttrR(Bool()) + idle = AttrR(bool) def __init__( self, diff --git a/src/fastcs/demo/temperature_attr.py b/src/fastcs/demo/temperature_attr.py index 756559bc8..89a7caabc 100755 --- a/src/fastcs/demo/temperature_attr.py +++ b/src/fastcs/demo/temperature_attr.py @@ -13,7 +13,7 @@ Nothing sits between the protocol and the attribute: no IO class hierarchy, no per-attribute ref object, no adapter. Because each method annotates its types, the datatype is inferred from them, so most attributes do not restate it - only the ones -that want metadata the annotation cannot carry, like ``Float(prec=3)``. +that want metadata the annotation cannot carry, like ``precision=3``. Because the attributes are wired in ``__init__`` rather than the class body, each one can close over per-instance state - which is what lets a ramp's index be baked into @@ -33,7 +33,7 @@ from fastcs.attributes import AttrR, AttrRW, Polled from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import DType_T, Float, Waveform +from fastcs.datatypes import Array1D, DType_T from fastcs.logging import logger from fastcs.methods import command, scan @@ -140,7 +140,7 @@ def __init__(self, settings: TemperatureControllerSettings) -> None: ) self.power = AttrR(getter=Polled(self._protocol.get_power, period=0.2)) # Updated by the update_voltages scan below, so no IO of its own - self.voltages = AttrR(Waveform(np.int32, shape=(4,))) + self.voltages = AttrR(Array1D[np.int32], shape=(4,)) self.ramps = ControllerVector( { @@ -210,10 +210,10 @@ def __init__(self, index: int, conn: IPConnection) -> None: # Stated explicitly, to carry metadata the annotation cannot: `-> float` # says nothing about display precision. self.target = AttrR( - Float(prec=3), getter=Polled(self._protocol.get_target, period=0.2) + float, precision=3, getter=Polled(self._protocol.get_target, period=0.2) ) self.actual = AttrR( - Float(prec=3), getter=Polled(self._protocol.get_actual, period=0.2) + float, precision=3, getter=Polled(self._protocol.get_actual, period=0.2) ) # Updated by the parent controller's update_voltages scan - self.voltage = AttrR(Float(prec=3)) + self.voltage = AttrR(float, precision=3) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 4be9245a0..ddffc3cb8 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -2,13 +2,14 @@ from collections import Counter from typing import Any, Literal +import numpy as np from softioc import builder, softioc from softioc.asyncio_dispatcher import AsyncioDispatcher from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import DType_T, Waveform +from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DType_T from fastcs.logging import logger from fastcs.methods import Command from fastcs.tracer import Tracer @@ -126,11 +127,11 @@ def _create_and_link_attribute_pvs( for attr_name, attribute in controller_api.attributes.items(): if ( - isinstance(attribute.datatype, Waveform) - and len(attribute.datatype.shape) != 1 + issubclass(attribute.dtype, np.ndarray) + and len(attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE)) != 1 ): logger.warning( - "Only 1D Waveform attributes are supported in EPICS CA transport", + "Only 1D array attributes are supported in EPICS CA transport", attribute=attribute, ) continue @@ -206,7 +207,7 @@ async def async_record_set(value: DType_T): "PV set from attribute", topic=attribute, pv=pv, value=repr(value) ) - record.set(cast_to_epics_type(attribute.datatype, value)) + record.set(cast_to_epics_type(attribute, value)) record = _make_in_record(pv, attribute) @@ -229,14 +230,14 @@ def _create_and_link_write_pv( async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) - await attribute.set(cast_from_epics_type(attribute.datatype, value)) + await attribute.set(cast_from_epics_type(attribute, value)) async def set_setpoint_without_process(value: DType_T): tracer.log_event( "PV setpoint set from attribute", topic=attribute, pv=pv, value=repr(value) ) - record.set(cast_to_epics_type(attribute.datatype, value), process=False) + record.set(cast_to_epics_type(attribute, value), process=False) record = _make_out_record(pv, attribute, on_update=on_update) diff --git a/src/fastcs/transports/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py index c6473afbf..374108a39 100644 --- a/src/fastcs/transports/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -1,15 +1,22 @@ import enum import re from collections.abc import Callable -from dataclasses import asdict -from typing import Any +from typing import Any, cast +import numpy as np from softioc import builder from softioc.pythonSoftIoc import RecordWrapper -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + DType, + DType_T, + Meta, + NumericLimits, +) from fastcs.exceptions import FastCSError from fastcs.transports.epics.util import validate_epics_pv_id @@ -50,226 +57,234 @@ def validate_ca_id(controller_api: ControllerAPI) -> None: MBB_MAX_CHOICES = len(_MBB_FIELD_PREFIXES) -EPICS_ALLOWED_DATATYPES = (Bool, Enum, Float, Int, String, Waveform) DEFAULT_STRING_WAVEFORM_LENGTH = 256 -DATATYPE_FIELD_TO_IN_RECORD_FIELD = { - "prec": "PREC", - "units": "EGU", - "min_alarm": "LOPR", - "max_alarm": "HOPR", -} -DATATYPE_FIELD_TO_OUT_RECORD_FIELD = { - "prec": "PREC", - "units": "EGU", - "min": "DRVL", - "max": "DRVH", - "min_alarm": "LOPR", - "max_alarm": "HOPR", -} +def is_epics_supported(dtype: type[DType]) -> bool: + """Whether EPICS CA can serve an attribute of this datatype.""" + return ( + dtype in (bool, int, float, str) + or issubclass(dtype, enum.Enum) + or issubclass(dtype, np.ndarray) + ) + + +def enum_names(dtype: type[enum.Enum]) -> list[str]: + """The names of an enum's members, in declaration order.""" + return [member.name for member in dtype] + + +def _display_limit_fields(meta: Meta) -> dict[str, Any]: + """The record fields for the range an attribute is displayed over.""" + limits: NumericLimits | None = meta.get("limits") + display = limits.display if limits is not None else None + + return { + "LOPR": display.low if display is not None else None, + "HOPR": display.high if display is not None else None, + } + + +def _control_limit_fields(meta: Meta) -> dict[str, Any]: + """The record fields for the range an attribute may be driven to.""" + limits: NumericLimits | None = meta.get("limits") + control = limits.control if limits is not None else None + + return { + "DRVL": control.low if control is not None else None, + "DRVH": control.high if control is not None else None, + } + + +def _string_length(meta: Meta) -> int: + return (meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH) + 1 + + +def _array_length(meta: Meta) -> int: + return meta.get("shape", DEFAULT_ARRAY_SHAPE)[0] def _make_in_record(pv: str, attribute: AttrR) -> RecordWrapper: + meta = attribute.meta + dtype = attribute.dtype common_fields = { "DESC": attribute.description, - "initial_value": cast_to_epics_type(attribute.datatype, attribute.readback), + "initial_value": cast_to_epics_type(attribute, attribute.readback), } - match attribute.datatype: - case Bool(): - record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields) - case Int(): - record = builder.longIn( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - **common_fields, - ) - case Float(): - record = builder.aIn( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - PREC=attribute.datatype.prec, - **common_fields, - ) - case String(): - record = builder.longStringIn( - pv, - length=(attribute.datatype.length + 1) - if attribute.datatype.length - else DEFAULT_STRING_WAVEFORM_LENGTH + 1, - **common_fields, - ) - case Enum(): - if len(attribute.datatype.members) > MBB_MAX_CHOICES: - record = builder.longStringIn( - pv, - **common_fields, - ) - else: - common_fields.update(create_state_keys(attribute.datatype)) - record = builder.mbbIn( - pv, - **common_fields, - ) - case Waveform(): - record = builder.WaveformIn( - pv, length=attribute.datatype.shape[0], **common_fields - ) - case _: - raise FastCSError( - f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" - ) - - def datatype_updater(datatype: DataType): - for name, value in asdict(datatype).items(): - if name in DATATYPE_FIELD_TO_IN_RECORD_FIELD: - record.set_field(DATATYPE_FIELD_TO_IN_RECORD_FIELD[name], value) - - attribute.add_update_datatype_callback(datatype_updater) + if dtype is bool: + record = builder.boolIn(pv, ZNAM="False", ONAM="True", **common_fields) + elif dtype is int: + record = builder.longIn( + pv, + EGU=meta.get("units"), + **_display_limit_fields(meta), + **common_fields, + ) + elif dtype is float: + record = builder.aIn( + pv, + EGU=meta.get("units"), + PREC=meta.get("precision", DEFAULT_PRECISION), + **_display_limit_fields(meta), + **common_fields, + ) + elif dtype is str: + record = builder.longStringIn(pv, length=_string_length(meta), **common_fields) + elif issubclass(dtype, enum.Enum): + if len(enum_names(dtype)) > MBB_MAX_CHOICES: + record = builder.longStringIn(pv, **common_fields) + else: + common_fields.update(create_state_keys(dtype)) + record = builder.mbbIn(pv, **common_fields) + elif issubclass(dtype, np.ndarray): + record = builder.WaveformIn(pv, length=_array_length(meta), **common_fields) + else: + raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}") + + _mirror_meta_onto_record(attribute, record, _in_record_fields) return record def _make_out_record(pv: str, attribute: AttrW, on_update: Callable) -> RecordWrapper: + meta = attribute.meta + dtype = attribute.dtype common_fields = { "DESC": attribute.description, "initial_value": cast_to_epics_type( - attribute.datatype, + attribute, attribute.readback if isinstance(attribute, AttrRW) - else attribute.datatype.initial_value, + else attribute.default_value(), ), "on_update": on_update, "always_update": True, "blocking": True, } - match attribute.datatype: - case Bool(): - record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields) - case Int(): - record = builder.longOut( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - DRVL=attribute.datatype.min, - DRVH=attribute.datatype.max, - **common_fields, - ) - case Float(): - record = builder.aOut( - pv, - LOPR=attribute.datatype.min_alarm, - HOPR=attribute.datatype.max_alarm, - EGU=attribute.datatype.units, - PREC=attribute.datatype.prec, - DRVL=attribute.datatype.min, - DRVH=attribute.datatype.max, - **common_fields, - ) - case String(): + if dtype is bool: + record = builder.boolOut(pv, ZNAM="False", ONAM="True", **common_fields) + elif dtype is int: + record = builder.longOut( + pv, + EGU=meta.get("units"), + **_display_limit_fields(meta), + **_control_limit_fields(meta), + **common_fields, + ) + elif dtype is float: + record = builder.aOut( + pv, + EGU=meta.get("units"), + PREC=meta.get("precision", DEFAULT_PRECISION), + **_display_limit_fields(meta), + **_control_limit_fields(meta), + **common_fields, + ) + elif dtype is str: + record = builder.longStringOut(pv, length=_string_length(meta), **common_fields) + elif issubclass(dtype, enum.Enum): + names = enum_names(dtype) + if len(names) > MBB_MAX_CHOICES: + + def _verify_in_names(_, value): + return value in names + record = builder.longStringOut( - pv, - length=(attribute.datatype.length + 1) - if attribute.datatype.length - else DEFAULT_STRING_WAVEFORM_LENGTH + 1, - **common_fields, - ) - case Enum(): - if len(attribute.datatype.members) > MBB_MAX_CHOICES: - datatype: Enum = attribute.datatype - - def _verify_in_datatype(_, value): - return value in datatype.names - - record = builder.longStringOut( - pv, - validate=_verify_in_datatype, - **common_fields, - ) - - else: - common_fields.update(create_state_keys(attribute.datatype)) - record = builder.mbbOut( - pv, - **common_fields, - ) - case Waveform(): - record = builder.WaveformOut( - pv, - length=attribute.datatype.shape[0], - **common_fields, - ) - case _: - raise FastCSError( - f"EPICS unsupported datatype on {attribute}: {attribute.datatype}" + pv, validate=_verify_in_names, **common_fields ) + else: + common_fields.update(create_state_keys(dtype)) + record = builder.mbbOut(pv, **common_fields) + elif issubclass(dtype, np.ndarray): + record = builder.WaveformOut(pv, length=_array_length(meta), **common_fields) + else: + raise FastCSError(f"EPICS unsupported datatype on {attribute}: {dtype}") + + _mirror_meta_onto_record(attribute, record, _out_record_fields) + return record - def datatype_updater(datatype: DataType): - for name, value in asdict(datatype).items(): - if name in DATATYPE_FIELD_TO_OUT_RECORD_FIELD: - record.set_field(DATATYPE_FIELD_TO_OUT_RECORD_FIELD[name], value) - attribute.add_update_datatype_callback(datatype_updater) - return record +def _in_record_fields(meta: Meta) -> dict[str, Any]: + return { + "PREC": meta.get("precision"), + "EGU": meta.get("units"), + **_display_limit_fields(meta), + } + + +def _out_record_fields(meta: Meta) -> dict[str, Any]: + return {**_in_record_fields(meta), **_control_limit_fields(meta)} + +def _mirror_meta_onto_record( + attribute: Attribute, + record: RecordWrapper, + fields_from_meta: Callable[[Meta], dict[str, Any]], +) -> None: + """Push later metadata changes - new units, say - onto the record.""" -def create_state_keys(datatype: Enum): + def meta_updater(meta: Meta) -> None: + for field, value in fields_from_meta(meta).items(): + if value is not None: + record.set_field(field, value) + + attribute.add_update_meta_callback(meta_updater) + + +def create_state_keys(dtype: type[enum.Enum]) -> dict[str, str]: """Creates a dictionary of state field keys to names""" return dict( zip( MBB_STATE_FIELDS, - datatype.names, + enum_names(dtype), strict=False, ) ) -def cast_from_epics_type(datatype: DataType[DType_T], value: object) -> DType_T: - """Casts from an EPICS datatype to a FastCS datatype.""" - match datatype: - case Bool(): - if value == 0: - return False - elif value == 1: - return True - else: - raise ValueError(f"Invalid bool value from EPICS record {value}") - case Enum(): - if len(datatype.members) <= MBB_MAX_CHOICES: - assert isinstance(value, int), "Got non-integer value for Enum" - return datatype.validate(datatype.members[value]) - else: # enum backed by string record - assert isinstance(value, str), "Got non-string value for long Enum" - # python typing can't narrow the nested generic enum_cls - assert issubclass(datatype.enum_cls, enum.Enum), "Invalid Enum.enum_cls" - enum_member = datatype.enum_cls[value] - return datatype.validate(enum_member) - case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") - - -def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any: - """Casts from an attribute's datatype to an EPICS datatype.""" - match datatype: - case Enum(): - if len(datatype.members) <= MBB_MAX_CHOICES: - return datatype.index_of(datatype.validate(value)) - else: # enum backed by string record - return datatype.validate(value).name - case String() as string: - if string.length is not None: - return value[: string.length] - else: - return value[:DEFAULT_STRING_WAVEFORM_LENGTH] - case datatype if issubclass(type(datatype), EPICS_ALLOWED_DATATYPES): - return value - case _: - raise ValueError(f"Unsupported datatype {datatype}") +def cast_from_epics_type(attribute: Attribute[DType_T], value: object) -> DType_T: + """Casts from an EPICS value to an attribute's datatype.""" + dtype = attribute.dtype + + if dtype is bool: + if value == 0: + return False # pyright: ignore[reportReturnType] + elif value == 1: + return True # pyright: ignore[reportReturnType] + else: + raise ValueError(f"Invalid bool value from EPICS record {value}") + + if issubclass(dtype, enum.Enum): + if len(enum_names(dtype)) <= MBB_MAX_CHOICES: + assert isinstance(value, int), "Got non-integer value for Enum" + return attribute.validate(list(dtype)[value]) + # enum backed by string record + assert isinstance(value, str), "Got non-string value for long Enum" + return attribute.validate(dtype[value]) + + if is_epics_supported(dtype): + return attribute.validate(value) + + raise ValueError(f"Unsupported datatype {dtype}") + + +def cast_to_epics_type(attribute: Attribute[DType_T], value: DType_T) -> Any: + """Casts from an attribute's value to an EPICS value.""" + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + member = cast(enum.Enum, attribute.validate(value)) + if len(enum_names(dtype)) <= MBB_MAX_CHOICES: + return list(dtype).index(member) + # enum backed by string record + return member.name + + if dtype is str: + length = attribute.meta.get("length") or DEFAULT_STRING_WAVEFORM_LENGTH + return str(value)[:length] + + if is_epics_supported(dtype): + return value + + raise ValueError(f"Unsupported datatype {dtype}") diff --git a/src/fastcs/transports/epics/gui.py b/src/fastcs/transports/epics/gui.py index 7b4b45896..89cdf1282 100644 --- a/src/fastcs/transports/epics/gui.py +++ b/src/fastcs/transports/epics/gui.py @@ -1,3 +1,6 @@ +import enum + +import numpy as np from pvi.device import ( LED, ArrayTrace, @@ -24,14 +27,7 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import ( - Bool, - Enum, - Float, - Int, - String, - Waveform, -) +from fastcs.datatypes import DEFAULT_ARRAY_SHAPE, DEFAULT_PRECISION from fastcs.logging import logger from fastcs.methods import Command from fastcs.transports.epics.util import pv_prefix_from_path @@ -51,45 +47,50 @@ def _get_pv(self, attr_path: list[str], name: str): return f"{attr_prefix}:{snake_to_pascal(name)}" def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None: - match attribute.datatype: - case Bool(): - return LED() - case Int(): - return TextRead(precision=0) - case Float(prec=precision): - return TextRead(precision=precision) - case String(): - return TextRead(format=TextFormat.string) - case Enum(): - return TextRead(format=TextFormat.string) - case Waveform() as waveform: - if len(waveform.shape) > 1: - logger.warning( - "EPICS CA transport only supports 1D waveforms, " - f"{attribute} is a {len(waveform.shape)}D waveform" - ) - return None + dtype = attribute.dtype + if dtype is bool: + return LED() + if dtype is int: + return TextRead(precision=0) + if dtype is float: + return TextRead( + precision=attribute.meta.get("precision", DEFAULT_PRECISION) + ) + if dtype is str: + return TextRead(format=TextFormat.string) + if issubclass(dtype, enum.Enum): + return TextRead(format=TextFormat.string) + if issubclass(dtype, np.ndarray): + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) > 1: + logger.warning( + "EPICS CA transport only supports 1D waveforms, " + f"{attribute} is a {len(shape)}D waveform" + ) + return None + + return ArrayTrace(axis="x") - return ArrayTrace(axis="x") - case datatype: - raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") + raise TypeError(f"Unsupported type {dtype}") def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None: - match attribute.datatype: - case Bool(): - return ToggleButton() - case Int(): - return TextWrite(precision=0) - case Float(prec=precision): - return TextWrite(precision=precision) - case String(): - return TextWrite(format=TextFormat.string) - case Enum(): - return ComboBox(choices=attribute.datatype.names) - case Waveform(): - return None - case datatype: - raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") + dtype = attribute.dtype + if dtype is bool: + return ToggleButton() + if dtype is int: + return TextWrite(precision=0) + if dtype is float: + return TextWrite( + precision=attribute.meta.get("precision", DEFAULT_PRECISION) + ) + if dtype is str: + return TextWrite(format=TextFormat.string) + if issubclass(dtype, enum.Enum): + return ComboBox(choices=[member.name for member in dtype]) + if issubclass(dtype, np.ndarray): + return None + + raise TypeError(f"Unsupported type {dtype}") def _get_attribute_component( self, attr_path: list[str], name: str, attribute: Attribute diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 5668ce0d4..ea78820ef 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -1,3 +1,5 @@ +import enum + import numpy as np from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable @@ -7,7 +9,6 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.datatypes import Enum, Table from fastcs.methods import CommandCallback from fastcs.tracer import Tracer @@ -30,11 +31,12 @@ def __init__(self, attr_w: AttrW | AttrRW): async def put(self, pv: SharedPV, op: ServerOperation): value = op.value() - if isinstance(self._attr_w.datatype, Table): + structured_dtype = self._attr_w.meta.get("structured_dtype") + if structured_dtype is not None: assert isinstance(value, list) raw_value = np.array( [tuple(labelled_row.values()) for labelled_row in value], - dtype=self._attr_w.datatype.structured_dtype, + dtype=structured_dtype, ) elif isinstance(value, Value): raw_value = value.todict()["value"] @@ -48,7 +50,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): tracer.log_event("PV put", topic=self._attr_w, pv=pv, value=cast_value) - if isinstance(self._attr_w.datatype, Enum): + if issubclass(self._attr_w.dtype, enum.Enum): pv.post(cast_to_p4p_value(self._attr_w, cast_value)) else: pv.post(value) @@ -137,7 +139,7 @@ async def set_readback(value): def make_shared_write_pv(attribute: AttrW) -> SharedPV: shared_pv = SharedPV( handler=WritePvHandler(attribute), - initial=cast_to_p4p_value(attribute, attribute.datatype.initial_value), + initial=cast_to_p4p_value(attribute, attribute.default_value()), **_make_shared_pv_arguments(attribute), ) diff --git a/src/fastcs/transports/epics/pva/gui.py b/src/fastcs/transports/epics/pva/gui.py index 0ae6e4de1..5d25660d0 100644 --- a/src/fastcs/transports/epics/pva/gui.py +++ b/src/fastcs/transports/epics/pva/gui.py @@ -1,3 +1,4 @@ +import numpy as np from pvi.device import ( CheckBox, ImageColorMap, @@ -9,7 +10,10 @@ ) from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import Bool, Table, Waveform, numpy_to_fastcs_datatype +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + numpy_to_python_type, +) from fastcs.transports.epics.gui import EpicsGUI @@ -22,39 +26,42 @@ def _get_pv(self, attr_path: list[str], name: str): return f"pva://{super()._get_pv(attr_path, name)}" def _get_read_widget(self, attribute: Attribute) -> ReadWidgetUnion | None: - match attribute.datatype: - case Table(): - fastcs_datatypes = [ - numpy_to_fastcs_datatype(datatype) - for _, datatype in attribute.datatype.structured_dtype - ] - - base_get_read_widget = super()._get_read_widget - widgets = [ - base_get_read_widget(AttrR(datatype)) - for datatype in fastcs_datatypes - ] - - return TableRead(widgets=widgets) # type: ignore - case Waveform(shape=(height, width)): + structured_dtype = attribute.meta.get("structured_dtype") + if structured_dtype is not None: + column_types = [ + numpy_to_python_type(column_dtype) + for _, column_dtype in structured_dtype + ] + + base_get_read_widget = super()._get_read_widget + widgets = [ + base_get_read_widget(AttrR(column_type)) for column_type in column_types + ] + + return TableRead(widgets=widgets) # type: ignore + + if issubclass(attribute.dtype, np.ndarray): + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + if len(shape) == 2: + height, width = shape return ImageRead( height=height, width=width, color_map=ImageColorMap.GRAY ) - case _: - return super()._get_read_widget(attribute) + + return super()._get_read_widget(attribute) def _get_write_widget(self, attribute: Attribute) -> WriteWidgetUnion | None: - match attribute.datatype: - case Table(): - widgets = [] - for _, datatype in attribute.datatype.structured_dtype: - fastcs_datatype = numpy_to_fastcs_datatype(datatype) - if isinstance(fastcs_datatype, Bool): - # Replace with compact version for Table row - widget = CheckBox() - else: - widget = super()._get_write_widget(AttrW(fastcs_datatype)) - widgets.append(widget) - return TableWrite(widgets=widgets) - case _: - return super()._get_write_widget(attribute) + structured_dtype = attribute.meta.get("structured_dtype") + if structured_dtype is not None: + widgets = [] + for _, column_dtype in structured_dtype: + column_type = numpy_to_python_type(column_dtype) + if column_type is bool: + # Replace with compact version for Table row + widget = CheckBox() + else: + widget = super()._get_write_widget(AttrW(column_type)) + widgets.append(widget) + return TableWrite(widgets=widgets) + + return super()._get_write_widget(attribute) diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 75e979996..ea8accc0d 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -1,3 +1,4 @@ +import enum import math import time @@ -7,10 +8,14 @@ from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import Bool, DType, Enum, Float, Int, String, Table, Waveform -from fastcs.datatypes.datatype import DType_T - -P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, + DType, + DType_T, + Meta, + NumericLimits, +) # https://epics-base.github.io/pvxs/nt.html#alarm-t RECORD_ALARM_STATUS = 3 @@ -49,64 +54,78 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLi return p4p_dtypes +def is_p4p_supported(dtype: type[DType]) -> bool: + """Whether the PVA transport can serve an attribute of this datatype.""" + return ( + dtype in (bool, int, float, str) + or issubclass(dtype, enum.Enum) + or issubclass(dtype, np.ndarray) + ) + + def make_p4p_type( attribute: Attribute, ) -> NTScalar | NTEnum | NTNDArray | NTTable: - """Creates a p4p type for a given `Attribute` `DataType`.""" + """Creates a p4p type for a given `Attribute` datatype.""" display = isinstance(attribute, AttrR) control = isinstance(attribute, AttrW) - match attribute.datatype: - case Int(): - return NTScalar.buildType("i", display=display, control=control) - case Float(): - return NTScalar.buildType("d", display=display, control=control, form=True) - case String(): - return NTScalar.buildType("s", display=display, control=control) - case Bool(): - return NTScalar.buildType("?", display=display, control=control) - case Enum(): - return NTEnum() - case Waveform(): - # TODO: https://github.com/DiamondLightSource/FastCS/issues/123 - # * Make 1D scalar array for 1D shapes. - # This will require converting from np.int32 to "ai" - # if len(shape) == 1: - # return NTScalarArray(convert np.datatype32 to string "ad") - # * Add an option for allowing shape to change, if so we will - # use an NDArray here even if shape is 1D - - return NTNDArray() - case Table(structured_dtype): + dtype = attribute.dtype + + if dtype is bool: + return NTScalar.buildType("?", display=display, control=control) + if dtype is int: + return NTScalar.buildType("i", display=display, control=control) + if dtype is float: + return NTScalar.buildType("d", display=display, control=control, form=True) + if dtype is str: + return NTScalar.buildType("s", display=display, control=control) + if issubclass(dtype, enum.Enum): + return NTEnum() + if issubclass(dtype, np.ndarray): + if (structured_dtype := attribute.meta.get("structured_dtype")) is not None: # TODO: `NTEnum/NTNDArray/NTTable.wrap` don't accept extra fields until # https://github.com/epics-base/p4p/issues/166 return NTTable( columns=_table_with_numpy_dtypes_to_p4p_dtypes(structured_dtype) ) - case _: - raise RuntimeError(f"DataType `{attribute.datatype}` unsupported in P4P.") + + # TODO: https://github.com/DiamondLightSource/FastCS/issues/123 + # * Make 1D scalar array for 1D shapes. + # This will require converting from np.int32 to "ai" + # if len(shape) == 1: + # return NTScalarArray(convert np.datatype32 to string "ad") + # * Add an option for allowing shape to change, if so we will + # use an NDArray here even if shape is 1D + + return NTNDArray() + + raise RuntimeError(f"Datatype `{dtype}` unsupported in P4P.") def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T: """Converts from a p4p value to a FastCS `Attribute` value.""" - match attribute.datatype: - case Enum(): - assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum DataType" - index: int = value.index # pyright: ignore[reportAttributeAccessIssue] - return attribute.datatype.validate(attribute.datatype.members[index]) - case Waveform(shape=shape): - # p4p sends a flattened array - assert value.shape == (math.prod(shape),) - return attribute.datatype.validate(value.reshape(attribute.datatype.shape)) - case Table(structured_dtype): + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + assert hasattr(value, "index"), "Got non-enum p4p.Value for Enum datatype" + index: int = value.index # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(list(dtype)[index]) + + if issubclass(dtype, np.ndarray): + if (structured_dtype := attribute.meta.get("structured_dtype")) is not None: assert isinstance(value, np.ndarray) - return attribute.datatype.validate(np.array(value, dtype=structured_dtype)) - case attribute.datatype if issubclass( - type(attribute.datatype), P4P_ALLOWED_DATATYPES - ): - return attribute.datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {attribute.datatype}") + return attribute.validate(np.array(value, dtype=structured_dtype)) + + shape = attribute.meta.get("shape", DEFAULT_ARRAY_SHAPE) + # p4p sends a flattened array + assert value.shape == (math.prod(shape),) # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(value.reshape(shape)) # pyright: ignore[reportAttributeAccessIssue] + + if is_p4p_supported(dtype): + return attribute.validate(value) + + raise ValueError(f"Unsupported datatype {dtype}") def p4p_alarm_states( @@ -140,26 +159,33 @@ def p4p_timestamp_now() -> dict: def p4p_display(attribute: Attribute) -> dict: """Gets the p4p display structure for a given attribute.""" display = {} + meta = attribute.meta if attribute.description is not None: display["description"] = attribute.description - if isinstance(attribute.datatype, (Float | Int)): - if attribute.datatype.max is not None: - display["limitHigh"] = attribute.datatype.max - if attribute.datatype.min is not None: - display["limitLow"] = attribute.datatype.min - if attribute.datatype.units is not None: - display["units"] = attribute.datatype.units - if isinstance(attribute.datatype, Float): - if attribute.datatype.prec is not None: - display["precision"] = attribute.datatype.prec + if attribute.dtype in (int, float): + limits: NumericLimits | None = meta.get("limits") + if limits is not None: + if limits.control.high is not None: + display["limitHigh"] = limits.control.high + if limits.control.low is not None: + display["limitLow"] = limits.control.low + if (units := meta.get("units")) is not None: + display["units"] = units + if attribute.dtype is float: + display["precision"] = meta.get("precision", DEFAULT_PRECISION) if display: return {"display": display} return {} -def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> dict: - low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore - high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore +def _p4p_check_numeric_for_alarm_states(meta: Meta, value: DType) -> dict: + limits: NumericLimits | None = meta.get("limits") + alarm = limits.alarm if limits is not None else None + alarm_low = alarm.low if alarm is not None else None + alarm_high = alarm.high if alarm is not None else None + + low = None if alarm_low is None else value < alarm_low # type: ignore + high = None if alarm_high is None else value > alarm_high # type: ignore severity = ( MAJOR_ALARM_SEVERITY if high not in (None, False) or low not in (None, False) @@ -169,12 +195,12 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> if low: status, message = ( RECORD_ALARM_STATUS, - f"Below minimum alarm limit: {datatype.min_alarm}", + f"Below minimum alarm limit: {alarm_low}", ) if high: status, message = ( RECORD_ALARM_STATUS, - f"Above maximum alarm limit: {datatype.max_alarm}", + f"Above maximum alarm limit: {alarm_high}", ) return p4p_alarm_states(severity, status, message) @@ -182,34 +208,32 @@ def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: """Converts a FastCS ``Attribute`` value to a p4p value""" - match attribute.datatype: - case Enum(): - return { - "index": attribute.datatype.index_of(value), - "choices": attribute.datatype.names, - } - case Waveform(): - return attribute.datatype.validate(value) - case Table(): - return attribute.datatype.validate(value) - - case datatype if issubclass(type(datatype), P4P_ALLOWED_DATATYPES): - record_fields: dict = {"value": datatype.validate(value)} - if isinstance(attribute, AttrR): - record_fields.update(p4p_display(attribute)) - - if isinstance(datatype, (Float | Int)): - record_fields.update( - _p4p_check_numeric_for_alarm_states( - datatype, - value, - ) - ) - else: - record_fields.update(p4p_alarm_states()) - - record_fields.update(p4p_timestamp_now()) - - return Value(make_p4p_type(attribute), record_fields) - case _: - raise ValueError(f"Unsupported datatype {attribute.datatype}") + dtype = attribute.dtype + + if issubclass(dtype, enum.Enum): + members = list(dtype) + return { + "index": members.index(value), # pyright: ignore[reportArgumentType] + "choices": [member.name for member in members], + } + + if issubclass(dtype, np.ndarray): + return attribute.validate(value) + + if is_p4p_supported(dtype): + record_fields: dict = {"value": attribute.validate(value)} + if isinstance(attribute, AttrR): + record_fields.update(p4p_display(attribute)) + + if dtype in (int, float): + record_fields.update( + _p4p_check_numeric_for_alarm_states(attribute.meta, value) + ) + else: + record_fields.update(p4p_alarm_states()) + + record_fields.update(p4p_timestamp_now()) + + return Value(make_p4p_type(attribute), record_fields) + + raise ValueError(f"Unsupported datatype {dtype}") diff --git a/src/fastcs/transports/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py index 4ead1aa18..299543111 100644 --- a/src/fastcs/transports/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -10,7 +10,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes.datatype import DType_T +from fastcs.datatypes import DType_T from fastcs.exceptions import FastCSError from fastcs.logging import intercept_std_logger from fastcs.methods import Command @@ -151,8 +151,8 @@ async def _dynamic_f(value): # Add type annotations for validation, schema, conversions _dynamic_f.__name__ = attr_name - _dynamic_f.__annotations__["value"] = attribute.datatype.dtype - _dynamic_f.__annotations__["return"] = attribute.datatype.dtype + _dynamic_f.__annotations__["value"] = attribute.dtype + _dynamic_f.__annotations__["return"] = attribute.dtype return _dynamic_f @@ -166,7 +166,7 @@ async def _dynamic_f() -> DType_T: return attribute.readback _dynamic_f.__name__ = attr_name - _dynamic_f.__annotations__["return"] = attribute.datatype.dtype + _dynamic_f.__annotations__["return"] = attribute.dtype return _dynamic_f diff --git a/src/fastcs/transports/rest/rest.py b/src/fastcs/transports/rest/rest.py index 4f54195c9..53e6bd2d4 100644 --- a/src/fastcs/transports/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -7,7 +7,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes.datatype import DType_T +from fastcs.datatypes import DType_T from fastcs.logging import intercept_std_logger from fastcs.methods import Command from fastcs.util import snake_to_pascal @@ -57,8 +57,8 @@ def _put_request_body(attribute: AttrW[DType_T]): Creates a pydantic model for each datatype which defines the schema of the PUT request body """ - converted_datatype = convert_datatype(attribute.datatype) - type_name = str(attribute.datatype.dtype.__name__).title() + converted_datatype = convert_datatype(attribute.dtype) + type_name = str(attribute.dtype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( f"Put{type_name}Value", @@ -70,7 +70,7 @@ def _wrap_attr_put( attribute: AttrW[DType_T], ) -> Callable[[DType_T], Coroutine[Any, Any, None]]: async def attr_put(request): - await attribute.set(cast_from_rest_type(attribute.datatype, request.value)) + await attribute.set(cast_from_rest_type(attribute, request.value)) # Fast api uses type annotations for validation, schema, conversions attr_put.__annotations__["request"] = _put_request_body(attribute) @@ -83,7 +83,7 @@ def _get_response_body(attribute: AttrR[DType_T]): Creates a pydantic model for each datatype which defines the schema of the GET request body """ - converted_datatype = convert_datatype(attribute.datatype) + converted_datatype = convert_datatype(attribute.dtype) type_name = str(converted_datatype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( @@ -97,7 +97,7 @@ def _wrap_attr_get( ) -> Callable[[], Coroutine[Any, Any, dict[str, object]]]: async def attr_get() -> dict[str, object]: value = attribute.readback - return {"value": cast_to_rest_type(attribute.datatype, value)} + return {"value": cast_to_rest_type(attribute, value)} return attr_get diff --git a/src/fastcs/transports/rest/util.py b/src/fastcs/transports/rest/util.py index c869a0d9f..2e8406ca8 100644 --- a/src/fastcs/transports/rest/util.py +++ b/src/fastcs/transports/rest/util.py @@ -2,9 +2,8 @@ import numpy as np -from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform - -REST_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String) +from fastcs.attributes import Attribute +from fastcs.datatypes import DType, DType_T, array_dtype_of _REST_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -20,32 +19,25 @@ def validate_rest_id(id: str) -> None: ) -def convert_datatype(datatype: DataType[DType_T]) -> type[DType_T]: +def convert_datatype(dtype: type[DType]) -> type: """Converts a datatype to a rest serialisable type.""" - match datatype: - case Waveform(): - return list - case _: - return datatype.dtype + if issubclass(dtype, np.ndarray): + return list + + return dtype -def cast_to_rest_type(datatype: DataType[DType_T], value: DType_T) -> object: +def cast_to_rest_type(attribute: Attribute[DType_T], value: DType_T) -> object: """Casts from an attribute value to a rest value.""" - match datatype: - case Waveform(): - return value.tolist() - case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES): - return datatype.validate(value) - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, np.ndarray): + return value.tolist() # pyright: ignore[reportAttributeAccessIssue] + return attribute.validate(value) -def cast_from_rest_type(datatype: DataType[DType_T], value: object) -> DType_T: + +def cast_from_rest_type(attribute: Attribute[DType_T], value: object) -> DType_T: """Casts from a rest value to an attribute datatype.""" - match datatype: - case Waveform(): - return datatype.validate(np.array(value, dtype=datatype.array_dtype)) - case datatype if issubclass(type(datatype), REST_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, np.ndarray): + return attribute.validate(np.array(value, dtype=array_dtype_of(attribute.meta))) + + return attribute.validate(value) diff --git a/src/fastcs/transports/tango/dsr.py b/src/fastcs/transports/tango/dsr.py index 80aae1d2f..688d9a798 100644 --- a/src/fastcs/transports/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -31,7 +31,7 @@ def _wrap_updater_fget( ) -> Callable[[Any], Any]: async def fget(tango_device: Device): tango_device.info_stream(f"called fget method: {attr_name}") - return cast_to_tango_type(attribute.datatype, attribute.readback) + return cast_to_tango_type(attribute, attribute.readback) return fget @@ -55,7 +55,7 @@ def _wrap_updater_fset( ) -> Callable[[Any, Any], Any]: async def fset(tango_device: Device, value): tango_device.info_stream(f"called fset method: {attr_name}") - coro = attribute.set(cast_from_tango_type(attribute.datatype, value)) + coro = attribute.set(cast_from_tango_type(attribute, value)) await _run_threadsafe_blocking(coro, loop) return fset @@ -85,7 +85,7 @@ def _collect_dev_attributes( ), access=AttrWriteType.READ_WRITE, **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) case AttrR(): collection[d_attr_name] = server.attribute( @@ -93,7 +93,7 @@ def _collect_dev_attributes( access=AttrWriteType.READ, fget=_wrap_updater_fget(attr_name, attribute, controller_api), **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) case AttrW(): collection[d_attr_name] = server.attribute( @@ -103,7 +103,7 @@ def _collect_dev_attributes( attr_name, attribute, controller_api, loop ), **get_server_metadata_from_attribute(attribute), - **get_server_metadata_from_datatype(attribute.datatype), + **get_server_metadata_from_datatype(attribute), ) return collection diff --git a/src/fastcs/transports/tango/util.py b/src/fastcs/transports/tango/util.py index 9a82f264c..f1bd24348 100644 --- a/src/fastcs/transports/tango/util.py +++ b/src/fastcs/transports/tango/util.py @@ -1,24 +1,20 @@ +import enum import re -from dataclasses import asdict -from typing import Any +from typing import Any, cast +import numpy as np from tango import AttrDataFormat from fastcs.attributes import Attribute from fastcs.datatypes import ( - Bool, - DataType, + DEFAULT_ARRAY_SHAPE, + DEFAULT_PRECISION, DType, DType_T, - Enum, - Float, - Int, - String, - Waveform, + NumericLimits, + array_dtype_of, ) -TANGO_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String, Waveform) - _TANGO_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") @@ -55,15 +51,6 @@ def tango_dev_name(id: str, dsr_instance: str) -> str: return f"{id}/{tango_dev_class_name(id)}/{dsr_instance}" -DATATYPE_FIELD_TO_SERVER_FIELD = { - "units": "unit", - "min": "min_value", - "max": "max_value", - "min_alarm": "min_alarm", - "max_alarm": "min_alarm", -} - - def get_server_metadata_from_attribute( attribute: Attribute[DType], ) -> dict[str, Any]: @@ -73,33 +60,45 @@ def get_server_metadata_from_attribute( return arguments -def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, str]: - """Gets the metadata for a Tango field from a FastCS datatype.""" - arguments = { - DATATYPE_FIELD_TO_SERVER_FIELD[field]: value - for field, value in asdict(datatype).items() - if field in DATATYPE_FIELD_TO_SERVER_FIELD +def _limit_arguments(limits: NumericLimits | None) -> dict[str, Any]: + if limits is None: + return {} + + return { + "min_value": limits.control.low, + "max_value": limits.control.high, + "min_alarm": limits.alarm.low, + "max_alarm": limits.alarm.high, + "min_warning": limits.warning.low, + "max_warning": limits.warning.high, } - dtype = datatype.dtype - - match datatype: - case Waveform(): - dtype = datatype.array_dtype - match len(datatype.shape): - case 1: - arguments["max_dim_x"] = datatype.shape[0] - arguments["dformat"] = AttrDataFormat.SPECTRUM - case 2: - arguments["max_dim_x"], arguments["max_dim_y"] = datatype.shape - arguments["dformat"] = AttrDataFormat.IMAGE - case _: - raise TypeError( - f"Unsupported shape {datatype.shape}, Tango supports up " - "to 2D arrays" - ) - case Float(): - arguments["format"] = f"%.{datatype.prec}" + +def get_server_metadata_from_datatype(attribute: Attribute[DType]) -> dict[str, Any]: + """Gets the metadata for a Tango field from an attribute's datatype.""" + meta = attribute.meta + dtype: Any = attribute.dtype + + arguments: dict[str, Any] = {"unit": meta.get("units")} + arguments.update(_limit_arguments(meta.get("limits"))) + + if issubclass(attribute.dtype, np.ndarray): + dtype = array_dtype_of(meta) + shape = meta.get("shape", DEFAULT_ARRAY_SHAPE) + match len(shape): + case 1: + arguments["max_dim_x"] = shape[0] + arguments["dformat"] = AttrDataFormat.SPECTRUM + case 2: + arguments["max_dim_x"] = shape[0] + arguments["max_dim_y"] = shape[1] + arguments["dformat"] = AttrDataFormat.IMAGE + case _: + raise TypeError( + f"Unsupported shape {shape}, Tango supports up to 2D arrays" + ) + elif attribute.dtype is float: + arguments["format"] = f"%.{meta.get('precision', DEFAULT_PRECISION)}" arguments["dtype"] = dtype for argument, value in arguments.items(): @@ -109,24 +108,19 @@ def get_server_metadata_from_datatype(datatype: DataType[DType]) -> dict[str, st return arguments -def cast_to_tango_type(datatype: DataType[DType_T], value: DType_T) -> object: +def cast_to_tango_type(attribute: Attribute[DType_T], value: DType_T) -> object: """Casts a value from FastCS to tango datatype.""" - match datatype: - case Enum(): - return datatype.index_of(datatype.validate(value)) - case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES): - return datatype.validate(value) - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, enum.Enum): + member = cast(enum.Enum, attribute.validate(value)) + return list(attribute.dtype).index(member) + + return attribute.validate(value) -def cast_from_tango_type(datatype: DataType[DType_T], value: object) -> DType_T: +def cast_from_tango_type(attribute: Attribute[DType_T], value: object) -> DType_T: """Casts a value from tango to FastCS datatype.""" - match datatype: - case Enum(): - assert isinstance(value, int), "Got non-integer value for Enum" - return datatype.validate(datatype.members[value]) - case datatype if issubclass(type(datatype), TANGO_ALLOWED_DATATYPES): - return datatype.validate(value) # type: ignore - case _: - raise ValueError(f"Unsupported datatype {datatype}") + if issubclass(attribute.dtype, enum.Enum): + assert isinstance(value, int), "Got non-integer value for Enum" + return attribute.validate(list(attribute.dtype)[value]) + + return attribute.validate(value) diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index d429abd7a..8299bff91 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -6,14 +6,13 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Int from fastcs.methods import command, scan class TestSubController(Controller): def __init__(self) -> None: super().__init__() - self.read_int = AttrR(Int()) + self.read_int = AttrR(int) class MyTestController(Controller): diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py index fc2d187e9..19932655b 100644 --- a/tests/benchmarking/controller.py +++ b/tests/benchmarking/controller.py @@ -3,7 +3,6 @@ from fastcs import FastCS from fastcs.attributes import AttrR, AttrW from fastcs.controllers import Controller -from fastcs.datatypes import Bool, Int from fastcs.transports.epics.ca.transport import EpicsCATransport from fastcs.transports.rest.options import RestServerOptions from fastcs.transports.rest.transport import RestTransport @@ -11,8 +10,8 @@ class MyTestController(Controller): - read_int: AttrR = AttrR(Int(), initial_value=0) - write_bool: AttrW = AttrW(Bool()) + read_int: AttrR = AttrR(int, initial_value=0) + write_bool: AttrW = AttrW(bool) def run(): diff --git a/tests/conftest.py b/tests/conftest.py index 9c6563dfe..937526e78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,6 @@ from softioc import builder from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Float, Int, String from fastcs.logging import configure_logging, logger from fastcs.logging._logging import LogLevel from fastcs.transports.tango.dsr import FASTCS_TANGO_SERVER_NAME, register_dev @@ -42,12 +41,12 @@ def clear_softioc_records(): class BackendTestController(MyTestController): - read_int: AttrR = AttrR(Int()) - read_write_int: AttrRW = AttrRW(Int()) - read_write_float: AttrRW = AttrRW(Float()) - read_bool: AttrR = AttrR(Bool()) - write_bool: AttrW = AttrW(Bool()) - read_string: AttrRW = AttrRW(String()) + read_int: AttrR = AttrR(int) + read_write_int: AttrRW = AttrRW(int) + read_write_float: AttrRW = AttrRW(float) + read_bool: AttrR = AttrR(bool) + write_bool: AttrW = AttrW(bool) + read_string: AttrRW = AttrRW(str) @pytest.fixture diff --git a/tests/demo/test_eiger.py b/tests/demo/test_eiger.py index df35f758d..2b518c8bb 100644 --- a/tests/demo/test_eiger.py +++ b/tests/demo/test_eiger.py @@ -6,7 +6,6 @@ import pytest_asyncio from fastcs.attributes import AttrR, AttrRW -from fastcs.datatypes import Enum from fastcs.demo.eiger import UPDATE_PERIOD, EigerDetector from fastcs.demo.simulation.eiger import EigerParameter, create_eiger_sim_app from fastcs.util import ONCE @@ -39,13 +38,17 @@ async def sim(_eiger) -> SimState: @pytest.mark.asyncio async def test_hinted_attributes_are_introspected(detector: EigerDetector): assert isinstance(detector.count_time, AttrRW) - assert detector.count_time.datatype.dtype is float + assert detector.count_time.dtype is float assert isinstance(detector.state, AttrR) # ``state`` reports ``allowed_values``, so it is introspected as an enum whose # members come from the device rather than as a bare string. - assert isinstance(detector.state.datatype, Enum) - assert detector.state.datatype.names == ["idle", "ready", "acquire"] + assert issubclass(detector.state.dtype, enum.Enum) + assert [member.name for member in detector.state.dtype] == [ + "idle", + "ready", + "acquire", + ] @pytest.mark.asyncio diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index ce83fe43f..f41b74d6c 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -1,13 +1,15 @@ import asyncio import enum +from pathlib import Path import numpy as np from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform +from fastcs.datatypes import Array1D, Limits, NumericLimits, Table from fastcs.launch import FastCS from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsGUIOptions from fastcs.transports.epics.pva import EpicsPVATransport @@ -21,17 +23,23 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" - a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) + a: AttrRW = AttrRW( + int, + limits=NumericLimits(control=Limits(high=400_000), alarm=Limits(high=40_000)), + ) + b: AttrW = AttrW( + float, limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)) + ) table: AttrRW = AttrRW( - Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]), + Table, + structured_dtype=[("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)], ) class ChildController(Controller): fail_on_next_e = True - c: AttrW = AttrW(Int()) + c: AttrW = AttrW(int) def __init__(self, description: str | None = None): super().__init__(description=description) @@ -41,7 +49,7 @@ def __init__(self, description: str | None = None): # returns what it accepted, which becomes both the readback and the # setpoint; the getter seeds the setpoint when the controller connects. self._clamped = 5 - self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped) + self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped) async def get_clamped(self) -> int: return self._clamped @@ -57,15 +65,15 @@ async def d(self): print("D: FINISHED") await self.j.update(self.j.readback + 1) - e: AttrR = AttrR(Bool()) + e: AttrR = AttrR(bool) @scan(1) async def flip_flop(self): await self.e.update(not self.e.readback) - f: AttrRW = AttrRW(Enum(FEnum)) - g: AttrRW = AttrRW(Waveform(np.int64, shape=(3,))) - h: AttrRW = AttrRW(Waveform(np.float64, shape=(3, 3))) + f: AttrRW = AttrRW(FEnum) + g: AttrRW = AttrRW(Array1D[np.int64], shape=(3,)) + h: AttrRW = AttrRW(Array1D[np.float64], shape=(3, 3)) @command() async def i(self): @@ -79,16 +87,17 @@ async def i(self): print("I: FINISHED") await self.j.update(self.j.readback + 1) - j: AttrR = AttrR(Int()) + j: AttrR = AttrR(int) def run(id="P4P_TEST_DEVICE"): - p4p_options = EpicsPVATransport() + gui_options = EpicsGUIOptions(output_dir=Path("./opis"), title="Demo Vector") + p4p_options = EpicsPVATransport(gui=gui_options) controller = ParentController() controller.set_path([id]) class ChildVector(ControllerVector): - vector_attribute: AttrR = AttrR(Int()) + vector_attribute: AttrR = AttrR(int) def __init__(self, children, description=None): super().__init__(children, description) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index d58011d71..88da09e1e 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -3,7 +3,6 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.control_system import FastCS from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Int from fastcs.methods import command from fastcs.transports.epics.ca.transport import ( EpicsCAOptions, @@ -13,13 +12,13 @@ class ParentController(Controller): - a: AttrR = AttrR(Int()) - b: AttrRW = AttrRW(Int()) + a: AttrR = AttrR(int) + b: AttrRW = AttrRW(int) def __init__(self, description: str | None = None) -> None: super().__init__(description) self._clamped = 5 - self.clamped = AttrRW(Int(), getter=self.get_clamped, setter=self.set_clamped) + self.clamped = AttrRW(int, getter=self.get_clamped, setter=self.set_clamped) async def get_clamped(self) -> int: return self._clamped @@ -30,7 +29,7 @@ async def set_clamped(self, value: int) -> int: class ChildController(Controller): - c: AttrW = AttrW(Int()) + c: AttrW = AttrW(int) @command() async def d(self): diff --git a/tests/test_attribute_logging.py b/tests/test_attribute_logging.py index 4f54d5d3b..851c1056b 100644 --- a/tests/test_attribute_logging.py +++ b/tests/test_attribute_logging.py @@ -1,13 +1,12 @@ import pytest from fastcs.attributes import AttrR -from fastcs.datatypes import Int @pytest.mark.asyncio async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog): """log_event emits 'Attribute set' and 'Value validated' when tracing is on.""" - attr = AttrR(Int()) + attr = AttrR(int) attr.enable_tracing() await attr.update(42) @@ -19,7 +18,7 @@ async def test_attr_r_update_trace_logs_when_tracing_enabled(loguru_caplog): @pytest.mark.asyncio async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) await attr.update(42) @@ -30,7 +29,7 @@ async def test_attr_r_update_no_trace_logs_when_tracing_disabled(loguru_caplog): @pytest.mark.asyncio async def test_attr_r_update_logs_validation_error(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) with pytest.raises(ValueError): await attr.update("not_an_int") # type: ignore[arg-type] @@ -40,7 +39,7 @@ async def test_attr_r_update_logs_validation_error(loguru_caplog): @pytest.mark.asyncio async def test_attr_r_update_logs_callback_failure(loguru_caplog): - attr = AttrR(Int()) + attr = AttrR(int) async def failing_callback(_value: int): raise RuntimeError("callback failed") diff --git a/tests/test_attributes.py b/tests/test_attributes.py index e78d5c59e..a4d25f4c0 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,34 +1,42 @@ import asyncio from functools import partial +import numpy as np +import numpy.typing as npt import pytest from pytest_mock import MockerFixture from fastcs.attributes import AttrR, AttrRW, AttrW, NotPolled, Polled, Update from fastcs.controllers import Controller -from fastcs.datatypes import Float, Int, String +from fastcs.datatypes import ( + DEFAULT_ARRAY_SHAPE, + Array1D, + Limits, + Meta, + NumericLimits, + Table, +) from fastcs.util import ONCE def test_attribute_access_mode(): """Test that attributes have the correct access_mode property.""" - attr_r = AttrR(String()) + attr_r = AttrR(str) assert attr_r.access_mode == "r" - attr_w = AttrW(String()) + attr_w = AttrW(str) assert attr_w.access_mode == "w" - attr_rw = AttrRW(String()) + attr_rw = AttrRW(str) assert attr_rw.access_mode == "rw" def test_attr_r(): - attr = AttrR(String(), group="test group") + attr = AttrR(str, group="test group") assert not attr.has_getter() assert attr.poll_period is None - assert isinstance(attr.datatype, String) - assert attr.dtype == str + assert attr.dtype is str assert attr.group == "test group" assert attr.name == "" assert attr.path == [] @@ -52,7 +60,7 @@ async def get_value() -> float: return 1.5 attr = AttrR(getter=get_value) - assert isinstance(attr.datatype, Float) + assert attr.dtype is float def test_datatype_inferred_from_setter_annotation(): @@ -60,7 +68,7 @@ async def set_value(value: int) -> None: pass attr = AttrW(setter=set_value) - assert isinstance(attr.datatype, Int) + assert attr.dtype is int def test_datatype_required_when_not_inferable(): @@ -78,7 +86,7 @@ def test_datatype_required_when_not_inferable(): @pytest.mark.asyncio async def test_attr_update(): - attr = AttrRW(Int()) + attr = AttrRW(int) await attr.update(42) assert attr.readback == 42 @@ -99,7 +107,7 @@ async def test_poll(): async def do_update(): return 5 - attr = AttrR(Int(), getter=do_update) + attr = AttrR(int, getter=do_update) assert attr.has_getter() value = await attr.poll() @@ -112,7 +120,7 @@ async def test_poll_unwraps_update_wrapper(): async def do_update(): return Update(9, timestamp=123.0) - attr = AttrR(Int(), getter=do_update) + attr = AttrR(int, getter=do_update) value = await attr.poll() assert value == 9 assert attr.readback == 9 @@ -120,7 +128,7 @@ async def do_update(): @pytest.mark.asyncio async def test_poll_with_no_getter_raises(): - attr = AttrR(Int()) + attr = AttrR(int) with pytest.raises(RuntimeError, match="has no getter"): await attr.poll() @@ -131,7 +139,7 @@ async def test_poll_exception_propagates(): async def do_update(): raise ValueError("do_update failed") - attr = AttrR(Int(), getter=do_update) + attr = AttrR(int, getter=do_update) with pytest.raises(ValueError, match="do_update failed"): await attr.poll() @@ -142,25 +150,25 @@ async def do_update(): return 1 # A bare getter is read once, when the controller connects. - attr = AttrR(Int(), getter=do_update) + attr = AttrR(int, getter=do_update) assert attr.poll_period == ONCE # Wrapping it in Polled schedules it instead. - attr_explicit = AttrR(Int(), getter=Polled(do_update, period=0.5)) + attr_explicit = AttrR(int, getter=Polled(do_update, period=0.5)) assert attr_explicit.poll_period == 0.5 # NotPolled is never scheduled - on-demand poll() only. - attr_on_demand = AttrR(Int(), getter=NotPolled(do_update)) + attr_on_demand = AttrR(int, getter=NotPolled(do_update)) assert attr_on_demand.poll_period is None assert attr_on_demand.has_getter() - attr_no_getter = AttrR(Int()) + attr_no_getter = AttrR(int) assert attr_no_getter.poll_period is None @pytest.mark.asyncio async def test_wait_for_predicate(mocker: MockerFixture): - attr = AttrR(Int(), initial_value=0) + attr = AttrR(int, initial_value=0) async def update(attr: AttrR): while True: @@ -188,7 +196,7 @@ def predicate(v: int) -> bool: @pytest.mark.asyncio async def test_wait_for_value(mocker: MockerFixture): - attr = AttrR(Int(), initial_value=0) + attr = AttrR(int, initial_value=0) async def update(attr: AttrR): await asyncio.sleep(0.5) @@ -222,7 +230,7 @@ async def send(value, key): device[key] = value return value # accepted value echoes straight back to the readback - attr_r = AttrR(String()) + attr_r = AttrR(str) attr_r.add_readback_callback(partial(update_ui, key="state"), always=False) await attr_r.update(device["state"]) assert ui["state"] == "Idle" @@ -232,7 +240,7 @@ async def send(value, key): # Identical update does not trigger callback as always=False assert ui["update_count"] == 1 - attr_rw = AttrRW(Int(), setter=partial(send, key="number")) + attr_rw = AttrRW(int, setter=partial(send, key="number")) attr_rw.add_readback_callback(partial(update_ui, key="number")) await attr_rw.set(2) assert device["number"] == 2 @@ -242,7 +250,7 @@ async def send(value, key): @pytest.mark.asyncio async def test_soft_attribute_self_wires(): """With no getter/setter, AttrRW.set() pushes straight to readback.""" - attr = AttrRW(Int()) + attr = AttrRW(int) assert not attr.has_getter() assert not attr.has_setter() @@ -259,7 +267,7 @@ async def setter(value): accepted["value"] = value return value + 1 # device clamps/accepts a different value - attr = AttrRW(Int(), setter=setter) + attr = AttrRW(int, setter=setter) await attr.set(10) assert accepted["value"] == 10 @@ -272,7 +280,7 @@ async def test_setter_with_no_return_leaves_readback_untouched(): async def setter(value): return None - attr = AttrRW(Int(), setter=setter) + attr = AttrRW(int, setter=setter) await attr.set(5) assert attr.setpoint == 5 @@ -284,7 +292,7 @@ async def test_attrw_setter_return_value_updates_setpoint_cache(): async def setter(value): return value + 1 - attr = AttrW(Int(), setter=setter) + attr = AttrW(int, setter=setter) await attr.set(5) assert attr.setpoint == 6 @@ -295,7 +303,7 @@ async def test_set_setter_exception_is_caught_and_logged(mocker: MockerFixture): async def do_set(value): raise ValueError("do_set failed") - attr = AttrW(Int(), setter=do_set) + attr = AttrW(int, setter=do_set) mock_logger = mocker.patch("fastcs.attributes.attr_w.logger") # exception is caught, not raised, and the setpoint is still cached @@ -386,7 +394,7 @@ class DemoParameterController(Controller): async def initialise(self): self._connection = DummyConnection() await self._connection.connect() - dtype_mapping = {"int": Int, "float": Float} + dtype_mapping = {"int": int, "float": float} example_introspection_response = await self._connection.get( "config/introspect_api" ) @@ -396,9 +404,12 @@ async def initialise(self): ro = parameter_response["read_only"] name = parameter_response["name"] uri = f"{parameter_response['subsystem']}/{name}" - datatype = dtype_mapping[parameter_response["dtype"]]( - min=parameter_response.get("min", None), - max=parameter_response.get("max", None), + datatype = dtype_mapping[parameter_response["dtype"]] + limits = NumericLimits( + control=Limits( + low=parameter_response.get("min", None), + high=parameter_response.get("max", None), + ) ) async def getter(uri=uri) -> int | float: @@ -409,6 +420,7 @@ async def getter(uri=uri) -> int | float: datatype, getter=getter, initial_value=parameter_response.get("value", None), + limits=limits, ) else: @@ -421,6 +433,7 @@ async def setter(value, uri=uri): getter=getter, setter=setter, initial_value=parameter_response.get("value", None), + limits=limits, ) self.add_attribute(name, attr) @@ -439,3 +452,100 @@ async def setter(value, uri=uri): await c.int_parameter.set(20) assert c.int_parameter.readback == 20 + + +def test_metadata_is_held_on_the_attribute(): + attr = AttrRW(float, precision=3, units="degC", description="the temperature") + + assert attr.dtype is float + assert attr.meta == { + "precision": 3, + "units": "degC", + "description": "the temperature", + } + assert attr.description == "the temperature" + + +def test_metadata_the_datatype_has_no_use_for_is_rejected(): + # Also a static error - the constructor overloads unpack StrMeta for a str + # attribute - but the runtime check is what catches metadata arriving from a + # source the type checker never saw. + with pytest.raises(TypeError, match="'precision' is not valid metadata for str"): + AttrR(str, precision=3) # pyright: ignore[reportCallIssue, reportArgumentType] + + +def test_metadata_is_validated_when_replaced(): + attr = AttrR(int, units="counts") + + attr.update_meta(Meta(units="mm")) + assert attr.meta == {"units": "mm"} + + with pytest.raises(TypeError, match="'length' is not valid metadata for int"): + attr.update_meta(Meta(length=4)) + + +def test_update_meta_notifies_callbacks(): + attr = AttrR(int) + seen: list[Meta] = [] + attr.add_update_meta_callback(seen.append) + + attr.update_meta(Meta(units="mm")) + + assert seen == [{"units": "mm"}] + + +def test_array_element_type_comes_from_the_datatype(): + attr = AttrR(Array1D[np.int32], shape=(4,)) + + assert attr.dtype is np.ndarray + assert attr.meta.get("array_dtype") is np.int32 + assert np.array_equal(attr.readback, np.zeros(4, dtype=np.int32)) + + +def test_an_array_needs_neither_shape_nor_array_dtype(): + attr = AttrR(npt.NDArray[np.int32]) + + assert attr.dtype is np.ndarray + assert attr.meta.get("array_dtype") is np.int32 + assert attr.readback.dtype == np.int32 + assert attr.readback.shape == DEFAULT_ARRAY_SHAPE + + +def test_array_element_type_is_not_given_twice(): + with pytest.raises(TypeError, match="already given by the datatype subscript"): + AttrR(Array1D[np.int32], array_dtype=np.int64) + + +def test_a_table_needs_its_columns(): + with pytest.raises(TypeError, match="Table attribute needs its columns"): + AttrR(Table) + + +def test_structured_dtype_needs_a_table_datatype(): + with pytest.raises(TypeError, match="only valid for a Table attribute"): + # Statically an error too: structured_dtype belongs to TableMeta. + AttrR( # pyright: ignore[reportCallIssue] + Array1D[np.int32], # pyright: ignore[reportArgumentType] + structured_dtype=[("a", np.int32)], + ) + + +@pytest.mark.asyncio +async def test_control_limits_reject_out_of_range_values(): + attr = AttrRW(int, limits=NumericLimits(control=Limits(0, 10))) + + await attr.set(5) + assert attr.readback == 5 + + with pytest.raises(ValueError, match="greater than maximum 10"): + await attr.update(15) + + +@pytest.mark.asyncio +async def test_display_limits_do_not_reject_values(): + """Only the control range constrains a write; the rest are served, not enforced.""" + attr = AttrR(float, limits=NumericLimits(alarm=Limits(0.0, 10.0))) + + await attr.update(15.0) + + assert attr.readback == 15.0 diff --git a/tests/test_control_system.py b/tests/test_control_system.py index 66b78b192..77b48e20a 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -5,7 +5,6 @@ from fastcs.attributes import AttrR, NotPolled, Polled from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.methods import Command, command from fastcs.util import ONCE @@ -75,9 +74,9 @@ async def get_never(): class MyController(Controller): def __init__(self): super().__init__() - self.update_once = AttrR(Int(), getter=Polled(get_once, period=ONCE)) - self.update_quickly = AttrR(Int(), getter=Polled(get_quickly, period=0.1)) - self.update_never = AttrR(Int(), getter=NotPolled(get_never)) + self.update_once = AttrR(int, getter=Polled(get_once, period=ONCE)) + self.update_quickly = AttrR(int, getter=Polled(get_quickly, period=0.1)) + self.update_never = AttrR(int, getter=NotPolled(get_never)) controller = MyController() loop = asyncio.get_event_loop() diff --git a/tests/test_controllers.py b/tests/test_controllers.py index 5d5dfb4ed..c6d2a9ee9 100644 --- a/tests/test_controllers.py +++ b/tests/test_controllers.py @@ -5,7 +5,6 @@ from fastcs.attributes import AttrR, AttrRW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Enum, Float, Int from fastcs.methods import Command, Scan, command, scan @@ -33,22 +32,22 @@ class SomeSubController(Controller): def __init__(self): super().__init__() - sub_attribute = AttrR(Int()) + sub_attribute = AttrR(int) - root_attribute = AttrR(Int()) + root_attribute = AttrR(int) class SomeController(Controller): annotated_attr_not_defined_in_init: AttrR[int] - equal_attr = AttrR(Int()) - annotated_and_equal_attr: AttrR[int] = AttrR(Int()) + equal_attr = AttrR(int) + annotated_and_equal_attr: AttrR[int] = AttrR(int) def __init__(self, sub_controller: Controller): super().__init__() - self.attr_on_object = AttrR(Int()) + self.attr_on_object = AttrR(int) - self.attributes["_attributes_attr"] = AttrR(Int()) + self.attributes["_attributes_attr"] = AttrR(int) self.attributes["_attributes_attr_equal"] = self.equal_attr self.sub_controller = sub_controller @@ -85,13 +84,13 @@ async def noop() -> None: @pytest.mark.parametrize( "member_name, member_value, expected_error", [ - ("attr", AttrR(Float()), r"Cannot add attribute"), + ("attr", AttrR(float), r"Cannot add attribute"), ("attr", Controller(), r"Cannot add sub controller"), ("attr", Command(noop), r"Cannot add command"), - ("sub_controller", AttrR(Int()), r"Cannot add attribute"), + ("sub_controller", AttrR(int), r"Cannot add attribute"), ("sub_controller", Controller(), r"Cannot add sub controller"), ("sub_controller", Command(noop), r"Cannot add command"), - ("cmd", AttrR(Int()), r"Cannot add attribute"), + ("cmd", AttrR(int), r"Cannot add attribute"), ("cmd", Controller(), r"Cannot add sub controller"), ("cmd", Command(noop), r"Cannot add command"), ], @@ -100,7 +99,7 @@ def test_conflicting_attributes_and_controllers_and_commands( member_name, member_value, expected_error ): class ConflictingController(Controller): - attr = AttrR(Int()) + attr = AttrR(int) cmd = Command(noop) def __init__(self): @@ -163,10 +162,10 @@ class HintedController(Controller): controller = HintedController() with pytest.raises(RuntimeError, match="does not match defined datatype"): - controller.add_attribute("read_write_int", AttrRW(Float())) + controller.add_attribute("read_write_int", AttrRW(float)) with pytest.raises(RuntimeError, match="does not match defined access mode"): - controller.add_attribute("read_write_int", AttrR(Int())) + controller.add_attribute("read_write_int", AttrR(int)) with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): controller.read_write_int = 5 # type: ignore @@ -175,7 +174,7 @@ class HintedController(Controller): with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): controller._validate_type_hints() - controller.add_attribute("read_write_int", AttrRW(Int())) + controller.add_attribute("read_write_int", AttrRW(int)) def test_enum_attribute_hint_validation(): @@ -191,9 +190,9 @@ class HintedController(Controller): controller = HintedController() with pytest.raises(RuntimeError, match="does not match defined datatype"): - controller.add_attribute("enum", AttrRW(Enum(BadEnum))) + controller.add_attribute("enum", AttrRW(BadEnum)) - controller.add_attribute("enum", AttrRW(Enum(GoodEnum))) + controller.add_attribute("enum", AttrRW(GoodEnum)) @pytest.mark.asyncio @@ -233,12 +232,12 @@ class HintedController(Controller): def test_controller_api(): class MyTestController(Controller): - attr1: AttrRW[int] = AttrRW(Int()) + attr1: AttrRW[int] = AttrRW(int) def __init__(self): super().__init__(description="Controller for testing") - self.attr2 = AttrRW(Int()) + self.attr2 = AttrRW(int) @command() async def do_nothing(self): diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py index b0b26d562..6040421dd 100644 --- a/tests/test_datatypes.py +++ b/tests/test_datatypes.py @@ -1,115 +1,198 @@ -from enum import IntEnum +from enum import Enum, IntEnum import numpy as np +import numpy.typing as npt import pytest -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, Table, Waveform -from fastcs.datatypes._util import numpy_to_fastcs_datatype +from fastcs.datatypes import ( + Array1D, + Limits, + Meta, + NumericLimits, + Table, + default_value, + numpy_to_python_type, + resolve_datatype, + validate_meta, + validate_value, + values_equal, +) + + +class Colour(Enum): + RED = "red" -def test_base_validate(): - class TestInt(DataType[int]): - @property - def dtype(self) -> type[int]: - return int +_TABLE_META = Meta( + structured_dtype=[("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))] +) + +def test_coerces_to_the_datatype(): class MyIntEnum(IntEnum): A = 0 B = 1 - test_int = TestInt() - - assert test_int.validate("0") == 0 - assert test_int.validate(MyIntEnum.B) == 1 + assert validate_value(int, Meta(), "0") == 0 + assert validate_value(int, Meta(), MyIntEnum.B) == 1 with pytest.raises(ValueError, match="Failed to cast"): - test_int.validate("foo") + validate_value(int, Meta(), "foo") @pytest.mark.parametrize( - ["datatype", "init_args", "value"], + ["dtype", "meta", "value"], [ - (Int, {"min": 1}, 0), - (Int, {"max": -1}, 0), - (Float, {"min": 1}, 0.0), - (Float, {"max": -1}, 0.0), - (Enum, {"enum_cls": int}, 0), - (Waveform, {"array_dtype": "uint64", "shape": (1, 1)}, np.ndarray([1])), + (int, Meta(limits=NumericLimits(control=Limits(low=1))), 0), + (int, Meta(limits=NumericLimits(control=Limits(high=-1))), 0), + (float, Meta(limits=NumericLimits(control=Limits(low=1))), 0.0), + (float, Meta(limits=NumericLimits(control=Limits(high=-1))), 0.0), + ( + np.ndarray, + Meta(array_dtype="uint64", shape=(1, 1)), + np.ndarray([1]), + ), ], ) -def test_validate(datatype, init_args, value): +def test_rejects_values_outside_the_metadata(dtype, meta, value): with pytest.raises(ValueError): - datatype(**init_args).validate(value) + validate_value(dtype, meta, value) + + +def test_control_limits_default_to_the_display_range(): + limits = NumericLimits(display=Limits(0.0, 10.0)) + + assert limits.control == Limits(0.0, 10.0) + with pytest.raises(ValueError, match="less than minimum"): + validate_value(float, Meta(limits=limits), -1.0) + + +def test_warning_limits_default_to_the_alarm_range(): + assert NumericLimits(alarm=Limits(0, 10)).warning == Limits(0, 10) + + +def test_warning_limits_must_lie_within_the_alarm_range(): + with pytest.raises(ValueError, match="not within alarm limits"): + NumericLimits(alarm=Limits(0, 10), warning=Limits(-1, 11)) @pytest.mark.parametrize( - "numpy_type, fastcs_datatype", + "numpy_type, python_type", [ - (np.float16, Float()), - (np.float32, Float()), - (np.int16, Int()), - (np.int32, Int()), - (np.bool, Bool()), - (np.dtype("S1000"), String()), - (np.dtype("U25"), String()), - (np.dtype(">i4"), Int()), - (np.dtype("d"), Float()), + (np.float16, float), + (np.float32, float), + (np.int16, int), + (np.int32, int), + (np.bool, bool), + (np.dtype("S1000"), str), + (np.dtype("U25"), str), + (np.dtype(">i4"), int), + (np.dtype("d"), float), ], ) -def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): - assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) +def test_numpy_to_python_type(numpy_type, python_type): + assert numpy_to_python_type(numpy_type) is python_type @pytest.mark.parametrize( - "fastcs_datatype, value1, value2, expected", + "dtype, value1, value2, expected", [ - (Int(), 1, 1, True), - (Int(), 1, 2, False), - (Float(), 1.0, 1.0, True), - (Float(), 1.0, 2.0, False), - (Bool(), True, True, True), - (Bool(), True, False, False), - (String(), "foo", "foo", True), - (String(), "foo", "bar", False), - (Waveform(np.int16), np.array([1]), np.array([1]), True), - (Waveform(np.int16), np.array([1]), np.array([2]), False), + (int, 1, 1, True), + (int, 1, 2, False), + (float, 1.0, 1.0, True), + (float, 1.0, 2.0, False), + (bool, True, True, True), + (bool, True, False, False), + (str, "foo", "foo", True), + (str, "foo", "bar", False), + (np.ndarray, np.array([1]), np.array([1]), True), + (np.ndarray, np.array([1]), np.array([2]), False), ( - Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]), + np.ndarray, np.array([1, True, "foo"]), np.array([1, True, "foo"]), True, ), ( - Table([("int", np.int16), ("bool", np.bool), ("str", np.dtype("S10"))]), + np.ndarray, np.array([1, True, "foo"]), np.array([2, False, "bar"]), False, ), ], ) -def test_dataset_equal(fastcs_datatype: DataType, value1, value2, expected): - assert fastcs_datatype.equal(value1, value2) is expected +def test_values_equal(dtype, value1, value2, expected): + assert values_equal(dtype, value1, value2) is expected + + +def test_string_length(): + assert validate_value(str, Meta(length=10), "12345678901") == "1234567890" + assert validate_value(str, Meta(), "12345678901") == "12345678901" + + with pytest.raises(ValueError, match="String length must be >= 1"): + validate_meta(str, Meta(length=0)) + + +def test_float_is_rounded_to_its_precision(): + assert validate_value(float, Meta(precision=3), 1.23456) == 1.235 + assert validate_value(float, Meta(), 1.23456) == 1.23 @pytest.mark.parametrize( - "fastcs_datatype, values, expected", + "spelling, dtype, element_type", [ - (Int(), [1, 1], True), - (Int(), [1, 2], False), - (Float(), [1.0, 1.0], True), - (Float(), [1.0, 2.0], False), - (Bool(), [True, True], True), - (Bool(), [True, False], False), + (int, int, None), + (float, float, None), + (bool, bool, None), + (str, str, None), + (Array1D[np.int32], np.ndarray, np.int32), + # numpy's own alias for a subscripted array is the same spelling with an + # unbounded shape, so it resolves the same way. + (npt.NDArray[np.int32], np.ndarray, np.int32), + (np.ndarray, np.ndarray, None), + (Table, np.ndarray, None), + (Colour, Colour, None), ], ) -def test_dataset_all_equal(fastcs_datatype: DataType, values, expected): - assert fastcs_datatype.all_equal(values) is expected +def test_resolve_datatype(spelling, dtype, element_type): + assert resolve_datatype(spelling) == (dtype, element_type) -def test_string_length(): - assert String(length=10).validate("12345678901") == "1234567890" +@pytest.mark.parametrize("spelling", ["float", 3, list[int]]) +def test_resolve_datatype_rejects_unsupported_spellings(spelling): + with pytest.raises(TypeError): + resolve_datatype(spelling) - assert String().validate("12345678901") == "12345678901" - with pytest.raises(ValueError): - String(length=0) +@pytest.mark.parametrize( + "dtype, meta, expected", + [ + (int, Meta(), 0), + (float, Meta(), 0.0), + (bool, Meta(), False), + (str, Meta(), ""), + ], +) +def test_default_value(dtype, meta, expected): + assert default_value(dtype, meta) == expected + + +def test_default_value_of_an_array(): + assert np.array_equal( + default_value(np.ndarray, Meta(array_dtype=np.int32, shape=(3,))), + np.zeros(3, dtype=np.int32), + ) + + +def test_default_value_of_a_table(): + assert default_value(np.ndarray, _TABLE_META).size == 0 + + +def test_validate_meta_rejects_fields_the_datatype_has_no_use_for(): + with pytest.raises(TypeError, match="'precision' is not valid metadata for str"): + validate_meta(str, Meta(precision=3), "device_id") + + +def test_an_array_needs_an_element_type(): + with pytest.raises(TypeError, match="needs an element type"): + default_value(np.ndarray, Meta(shape=(3,))) diff --git a/tests/test_launch.py b/tests/test_launch.py index 17e935f4c..df7bdae58 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -13,7 +13,6 @@ from fastcs.attributes import AttrR from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.exceptions import LaunchError from fastcs.launch import ( _build_options_model, @@ -45,7 +44,7 @@ def __init__(self, arg): class IsHinted(Controller): - read = AttrR(Int()) + read = AttrR(int) def __init__(self, arg: SomeConfig) -> None: super().__init__() diff --git a/tests/test_multi_controller.py b/tests/test_multi_controller.py index bde9b2f38..0ae223c17 100644 --- a/tests/test_multi_controller.py +++ b/tests/test_multi_controller.py @@ -12,7 +12,6 @@ from fastcs.attributes import AttrR from fastcs.control_system import FastCS from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.transports.epics import EpicsDocsOptions, EpicsGUIOptions from fastcs.transports.epics.ca.transport import EpicsCATransport from fastcs.transports.epics.emission import INDEX_STEM @@ -26,11 +25,11 @@ class _IdController(Controller): class _OneAttrController(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _OtherAttrController(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def test_controller_api_path_uses_id(): @@ -303,7 +302,7 @@ class names, so ``DEV-1`` and ``DEV_1`` would silently override each other in class _LifecycleController(Controller): """Records lifecycle hook calls for end-to-end assertions.""" - foo = AttrR(Int()) + foo = AttrR(int) def __init__(self): super().__init__() @@ -325,7 +324,7 @@ async def disconnect(self): class _OtherLifecycleController(_LifecycleController): - bar = AttrR(Int()) + bar = AttrR(int) @pytest.mark.asyncio diff --git a/tests/test_typed_commands.py b/tests/test_typed_commands.py index 36e6b3b82..c53a78afc 100644 --- a/tests/test_typed_commands.py +++ b/tests/test_typed_commands.py @@ -14,7 +14,6 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Float from fastcs.methods import command from fastcs.transports.epics.ca.ioc import EpicsCAIOC from fastcs.transports.epics.gui import EpicsGUI @@ -30,7 +29,7 @@ class TypedCommandController(Controller): calls: list[tuple] = [] # The GraphQL transport refuses an API with nothing to read - position = AttrR(Float()) + position = AttrR(float) @command() async def stop(self) -> None: diff --git a/tests/transports/epics/ca/test_ca_util.py b/tests/transports/epics/ca/test_ca_util.py index 463a17dcd..6f56d2123 100644 --- a/tests/transports/epics/ca/test_ca_util.py +++ b/tests/transports/epics/ca/test_ca_util.py @@ -1,9 +1,11 @@ import enum +from typing import Any, cast import pytest +from fastcs.attributes import AttrR from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String +from fastcs.datatypes import Meta from fastcs.transports.epics.ca.util import ( cast_from_epics_type, cast_to_epics_type, @@ -11,6 +13,18 @@ ) +def attr(datatype, **meta) -> AttrR: + """An attribute to cast values for, standing in for a real controller's.""" + return AttrR(datatype, **meta) + + +class UnsupportedAttribute: + """Stands in for an attribute of a datatype no transport knows about.""" + + dtype = object + meta: Meta = {} + + class ShortEnum(enum.Enum): NOT = 0 TOO = 1 @@ -65,74 +79,74 @@ class ShortMixedEnum(enum.Enum): @pytest.mark.parametrize( - "datatype,input,output", + "attribute,input,output", [ - (Enum(ShortEnum), ShortEnum.TOO, 1), + (attr(ShortEnum), ShortEnum.TOO, 1), # in CA, enums with too many values become epics strings - (Enum(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value - (Enum(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value - (Enum(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value - (Int(), 4, 4), - (Float(), 1.0, 1.0), - (Bool(), True, True), - (String(), "a" * 257, "a" * 256), - (String(length=3), "1234", "123"), + (attr(LongMixedEnum), LongMixedEnum.BE, "BE"), # string value + (attr(LongMixedEnum), LongMixedEnum.EPICS, "EPICS"), # None value + (attr(LongMixedEnum), LongMixedEnum.MBB, "MBB"), # int value + (attr(int), 4, 4), + (attr(float), 1.0, 1.0), + (attr(bool), True, True), + (attr(str), "a" * 257, "a" * 256), + (attr(str, length=3), "1234", "123"), # shorter enums can be represented by integers from 0-15 - (Enum(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0), - (Enum(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1), - (Enum(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2), + (attr(ShortMixedEnum), ShortMixedEnum.STRING_MEMBER, 0), + (attr(ShortMixedEnum), ShortMixedEnum.INT_MEMBER, 1), + (attr(ShortMixedEnum), ShortMixedEnum.NONE_MEMBER, 2), ], ) -def test_casting_to_epics(datatype, input, output): - assert cast_to_epics_type(datatype, input) == output +def test_casting_to_epics(attribute, input, output): + assert cast_to_epics_type(attribute, input) == output @pytest.mark.parametrize( - "datatype, input", + "attribute, input", [ - # TODO cover Waveform and Table cases - (Enum(ShortEnum), LongEnum.TOO), # wrong enum.Enum class + # TODO cover Array1D and Table cases + (attr(ShortEnum), LongEnum.TOO), # wrong enum.Enum class ], ) -def test_cast_to_epics_validations(datatype, input): +def test_cast_to_epics_validations(attribute, input): with pytest.raises(ValueError): - cast_to_epics_type(datatype, input) + cast_to_epics_type(attribute, input) @pytest.mark.parametrize( - "datatype,from_epics,result", + "attribute,from_epics,result", [ # long enums backed by strings - (Enum(LongMixedEnum), "BE", LongMixedEnum.BE), # string value - (Enum(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value - (Enum(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value - (Int(), 4, 4), - (Float(), 1.0, 1.0), - (Bool(), True, True), - (String(), "hey", "hey"), - (Enum(ShortEnum), 2, ShortEnum.MANY), + (attr(LongMixedEnum), "BE", LongMixedEnum.BE), # string value + (attr(LongMixedEnum), "EPICS", LongMixedEnum.EPICS), # None value + (attr(LongMixedEnum), "MBB", LongMixedEnum.MBB), # int value + (attr(int), 4, 4), + (attr(float), 1.0, 1.0), + (attr(bool), True, True), + (attr(str), "hey", "hey"), + (attr(ShortEnum), 2, ShortEnum.MANY), # short enums backed by mbbi/mbbo - (Enum(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER), - (Enum(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER), - (Enum(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER), - (Bool(), 1, True), - (Bool(), 0, False), + (attr(ShortMixedEnum), 0, ShortMixedEnum.STRING_MEMBER), + (attr(ShortMixedEnum), 1, ShortMixedEnum.INT_MEMBER), + (attr(ShortMixedEnum), 2, ShortMixedEnum.NONE_MEMBER), + (attr(bool), 1, True), + (attr(bool), 0, False), ], ) -def test_cast_from_epics_type(datatype, from_epics, result): - assert cast_from_epics_type(datatype, from_epics) == result +def test_cast_from_epics_type(attribute, from_epics, result): + assert cast_from_epics_type(attribute, from_epics) == result @pytest.mark.parametrize( - "datatype, input", + "attribute, input", [ - (object(), 0), - (Bool(), 3), + (UnsupportedAttribute(), 0), + (attr(bool), 3), ], ) -def test_cast_from_epics_validations(datatype, input): +def test_cast_from_epics_validations(attribute, input): with pytest.raises(ValueError): - cast_from_epics_type(datatype, input) + cast_from_epics_type(cast(Any, attribute), input) @pytest.mark.parametrize("id", ["DEVICE", "my-id", "name_1", "ABC-123_xyz"]) diff --git a/tests/transports/epics/ca/test_gui.py b/tests/transports/epics/ca/test_gui.py index 46e000e15..b8586c469 100644 --- a/tests/transports/epics/ca/test_gui.py +++ b/tests/transports/epics/ca/test_gui.py @@ -22,7 +22,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.epics.emission import INDEX_STEM, emit_gui_files from fastcs.transports.epics.gui import EpicsGUI from fastcs.transports.epics.options import EpicsGUIOptions @@ -37,50 +37,50 @@ def test_get_pv(): @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Bool(), LED()), - (Int(), TextRead()), - (Float(), TextRead()), - (String(), TextRead(format=TextFormat.string)), - (Enum(ColourEnum), TextRead(format=TextFormat.string)), - (Waveform(array_dtype=np.int32), ArrayTrace(axis="x")), + (AttrR(bool), LED()), + (AttrR(int), TextRead()), + (AttrR(float), TextRead()), + (AttrR(str), TextRead(format=TextFormat.string)), + (AttrR(ColourEnum), TextRead(format=TextFormat.string)), + (AttrR(Array1D[np.int32]), ArrayTrace(axis="x")), ], ) -def test_get_attribute_component_r(datatype, widget): +def test_get_attribute_component_r(attribute, widget): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR( + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR( name="Attr", read_pv="DEVICE:Attr", read_widget=widget ) @pytest.mark.parametrize( - "datatype", + "attribute", [ - (Waveform(array_dtype=np.int32, shape=(10, 10))), + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 10)), ], ) -def test_get_attribute_component_r_signal_none(datatype): +def test_get_attribute_component_r_signal_none(attribute): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) is None @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Bool(), ToggleButton()), - (Int(), TextWrite()), - (Float(), TextWrite()), - (String(), TextWrite(format=TextFormat.string)), - (Enum(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])), + (AttrW(bool), ToggleButton()), + (AttrW(int), TextWrite()), + (AttrW(float), TextWrite()), + (AttrW(str), TextWrite(format=TextFormat.string)), + (AttrW(ColourEnum), ComboBox(choices=["RED", "GREEN", "BLUE"])), ], ) -def test_get_attribute_component_w(datatype, widget): +def test_get_attribute_component_w(attribute, widget): gui = EpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(datatype)) == SignalW( + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalW( name="Attr", write_pv="DEVICE:Attr", write_widget=widget ) @@ -90,16 +90,14 @@ def test_get_attribute_component_none(mocker): mocker.patch.object(gui, "_get_read_widget", return_value=None) mocker.patch.object(gui, "_get_write_widget", return_value=None) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(Int())) is None - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(Int())) is None - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(Int())) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(int)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrW(int)) is None + assert gui._get_attribute_component(["DEVICE"], "Attr", AttrRW(int)) is None def test_get_write_widget_none(): gui = EpicsGUI(ControllerAPI()) - assert ( - gui._get_write_widget(attribute=AttrR(Waveform(array_dtype=np.int32))) is None - ) + assert gui._get_write_widget(attribute=AttrR(Array1D[np.int32])) is None def test_get_components(controller): @@ -195,11 +193,11 @@ def test_get_command_component(): class _A(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _B(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def _api_with_id(cls, name): diff --git a/tests/transports/epics/ca/test_initial_value.py b/tests/transports/epics/ca/test_initial_value.py index b78090dd0..9563e7e55 100644 --- a/tests/transports/epics/ca/test_initial_value.py +++ b/tests/transports/epics/ca/test_initial_value.py @@ -7,7 +7,7 @@ import fastcs.transports.epics.ca.ioc as ca_ioc from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.launch import FastCS from fastcs.transports.epics.ca.transport import EpicsCATransport @@ -19,30 +19,32 @@ class InitialEnum(enum.Enum): class InitialValuesController(Controller): - int = AttrRW(Int(), initial_value=4) - float = AttrRW(Float(), initial_value=3.1) - bool = AttrRW(Bool(), initial_value=True) - enum = AttrRW(Enum(InitialEnum), initial_value=InitialEnum.B) - str = AttrRW(String(), initial_value="initial") - waveform = AttrRW( - Waveform(np.int64, shape=(10,)), + int_rw = AttrRW(int, initial_value=4) + float_rw = AttrRW(float, initial_value=3.1) + bool_rw = AttrRW(bool, initial_value=True) + enum_rw = AttrRW(InitialEnum, initial_value=InitialEnum.B) + str_rw = AttrRW(str, initial_value="initial") + waveform_rw = AttrRW( + Array1D[np.int64], initial_value=np.array(range(10), dtype=np.int64), + shape=(10,), ) - int_r = AttrR(Int(), initial_value=5) - float_r = AttrR(Float(), initial_value=4.1) - bool_r = AttrR(Bool(), initial_value=False) - enum_r = AttrR(Enum(InitialEnum), initial_value=InitialEnum.C) - str_r = AttrR(String(), initial_value="initial_r") + int_r = AttrR(int, initial_value=5) + float_r = AttrR(float, initial_value=4.1) + bool_r = AttrR(bool, initial_value=False) + enum_r = AttrR(InitialEnum, initial_value=InitialEnum.C) + str_r = AttrR(str, initial_value="initial_r") waveform_r = AttrR( - Waveform(np.int64, shape=(10,)), + Array1D[np.int64], initial_value=np.array(range(10, 20), dtype=np.int64), + shape=(10,), ) - int_w = AttrW(Int()) - float_w = AttrW(Float()) - bool_w = AttrW(Bool()) - enum_w = AttrW(Enum(InitialEnum)) - str_w = AttrW(String()) - waveform_w = AttrW(Waveform(np.int64, shape=(10,))) + int_w = AttrW(int) + float_w = AttrW(float) + bool_w = AttrW(bool) + enum_w = AttrW(InitialEnum) + str_w = AttrW(str) + waveform_w = AttrW(Array1D[np.int64], shape=(10,)) @pytest.mark.forked @@ -73,27 +75,27 @@ async def test_initial_values_set_in_ca(mocker): for wrapper in record_spy.spy_return_list + record_spy_out.spy_return_list } for name, value in { - "SOFTIOC_INITIAL_DEVICE:Bool": 1, + "SOFTIOC_INITIAL_DEVICE:BoolRw": 1, "SOFTIOC_INITIAL_DEVICE:BoolR": 0, "SOFTIOC_INITIAL_DEVICE:BoolW": 0, - "SOFTIOC_INITIAL_DEVICE:Bool_RBV": 1, - "SOFTIOC_INITIAL_DEVICE:Enum": 1, + "SOFTIOC_INITIAL_DEVICE:BoolRw_RBV": 1, + "SOFTIOC_INITIAL_DEVICE:EnumRw": 1, "SOFTIOC_INITIAL_DEVICE:EnumR": 2, "SOFTIOC_INITIAL_DEVICE:EnumW": 0, - "SOFTIOC_INITIAL_DEVICE:Enum_RBV": 1, - "SOFTIOC_INITIAL_DEVICE:Float": 3.1, + "SOFTIOC_INITIAL_DEVICE:EnumRw_RBV": 1, + "SOFTIOC_INITIAL_DEVICE:FloatRw": 3.1, "SOFTIOC_INITIAL_DEVICE:FloatR": 4.1, "SOFTIOC_INITIAL_DEVICE:FloatW": 0.0, - "SOFTIOC_INITIAL_DEVICE:Float_RBV": 3.1, - "SOFTIOC_INITIAL_DEVICE:Int": 4, + "SOFTIOC_INITIAL_DEVICE:FloatRw_RBV": 3.1, + "SOFTIOC_INITIAL_DEVICE:IntRw": 4, "SOFTIOC_INITIAL_DEVICE:IntR": 5, "SOFTIOC_INITIAL_DEVICE:IntW": 0, - "SOFTIOC_INITIAL_DEVICE:Int_RBV": 4, - "SOFTIOC_INITIAL_DEVICE:Str": "initial", + "SOFTIOC_INITIAL_DEVICE:IntRw_RBV": 4, + "SOFTIOC_INITIAL_DEVICE:StrRw": "initial", "SOFTIOC_INITIAL_DEVICE:StrR": "initial_r", "SOFTIOC_INITIAL_DEVICE:StrW": "", - "SOFTIOC_INITIAL_DEVICE:Str_RBV": "initial", - "SOFTIOC_INITIAL_DEVICE:Waveform": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "SOFTIOC_INITIAL_DEVICE:StrRw_RBV": "initial", + "SOFTIOC_INITIAL_DEVICE:WaveformRw": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "SOFTIOC_INITIAL_DEVICE:WaveformR": [ 10, 11, @@ -107,7 +109,7 @@ async def test_initial_values_set_in_ca(mocker): 19, ], "SOFTIOC_INITIAL_DEVICE:WaveformW": 10 * [0], - "SOFTIOC_INITIAL_DEVICE:Waveform_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "SOFTIOC_INITIAL_DEVICE:WaveformRw_RBV": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], }.items(): assert np.array_equal(value, initial_values[name]) except Exception as e: diff --git a/tests/transports/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py index ece949116..a29958cdd 100644 --- a/tests/transports/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -14,7 +14,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D, Limits, Meta, NumericLimits from fastcs.exceptions import FastCSError from fastcs.methods import Command from fastcs.transports.epics.ca import EpicsCATransport @@ -52,7 +52,7 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): ) record = make_record.return_value - attribute = AttrR(Int()) + attribute = AttrR(int) attribute.add_readback_callback = mocker.MagicMock() _create_and_link_read_pv("PREFIX", "PV", "attr", None, attribute) @@ -151,17 +151,17 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture): "attribute,record_type,kwargs", ( ( - AttrR(String()), + AttrR(str), "longStringIn", {"length": 257, "DESC": None, "initial_value": ""}, ), ( - AttrR(String(length=10)), + AttrR(str, length=10), "longStringIn", {"length": 11, "DESC": None, "initial_value": ""}, ), ( - AttrR(Enum(ColourEnum)), + AttrR(ColourEnum), "mbbIn", { "ZRST": "RED", @@ -173,18 +173,16 @@ async def test_ioc_raises_if_duplicate_aliases_provided(mocker: MockerFixture): ), ( AttrR( - Enum( - enum.IntEnum( - "ONOFF_STATES", - {"DISABLED": 0, "ENABLED": 1}, - ) + enum.IntEnum( + "ONOFF_STATES", + {"DISABLED": 0, "ENABLED": 1}, ) ), "mbbIn", {"ZRST": "DISABLED", "ONST": "ENABLED", "DESC": None, "initial_value": 0}, ), ( - AttrR(Waveform(np.int32, (10,))), + AttrR(Array1D[np.int32], shape=(10,)), "WaveformIn", { "DESC": None, @@ -212,11 +210,17 @@ def test_make_input_record( ) +def _attribute_of_unsupported_datatype(mocker: MockerFixture): + attribute = mocker.MagicMock() + attribute.dtype = object + return attribute + + def test_make_record_raises(mocker: MockerFixture): mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type") - # Pass a mock as attribute to provoke the fallback case matching on datatype + # An attribute of a datatype EPICS cannot serve, to provoke the fallback with pytest.raises(FastCSError): - _make_in_record("PV", mocker.MagicMock()) + _make_in_record("PV", _attribute_of_unsupported_datatype(mocker)) @pytest.mark.asyncio @@ -227,7 +231,7 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): ) record = make_record.return_value - attribute = AttrRW(Int()) + attribute = AttrRW(int) attribute.set = mocker.AsyncMock() attribute.add_setpoint_callback = mocker.MagicMock() @@ -279,7 +283,7 @@ class LongEnum(enum.Enum): "attribute,record_type,kwargs", ( ( - AttrW(Enum(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1}))), + AttrW(enum.IntEnum("ONOFF_STATES", {"DISABLED": 0, "ENABLED": 1})), "mbbOut", { "ZRST": "DISABLED", @@ -289,12 +293,12 @@ class LongEnum(enum.Enum): }, ), ( - AttrW(String()), + AttrW(str), "longStringOut", {"length": 257, "DESC": None, "initial_value": ""}, ), ( - AttrW(String(length=10)), + AttrW(str, length=10), "longStringOut", {"length": 11, "DESC": None, "initial_value": ""}, ), @@ -323,7 +327,7 @@ def test_make_output_record( def test_long_enum_validator(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") update = mocker.MagicMock() - attribute = AttrRW(Enum(LongEnum)) + attribute = AttrRW(LongEnum) pv = "PV" record = _make_out_record(pv, attribute, on_update=update) validator = builder.longStringOut.call_args.kwargs["validate"] @@ -333,7 +337,7 @@ def test_long_enum_validator(mocker: MockerFixture): def test_long_enum_in_creation(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") - attribute = AttrR(Enum(LongEnum)) + attribute = AttrR(LongEnum) pv = "PV" _make_in_record(pv, attribute) assert builder.longStringIn.call_args.kwargs["initial_value"] == "THIS" @@ -341,20 +345,24 @@ def test_long_enum_in_creation(mocker: MockerFixture): def test_get_output_record_raises(mocker: MockerFixture): mocker.patch("fastcs.transports.epics.ca.util.cast_to_epics_type") - # Pass a mock as attribute to provoke the fallback case matching on datatype + # An attribute of a datatype EPICS cannot serve, to provoke the fallback with pytest.raises(FastCSError): - _make_out_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) + _make_out_record( + "PV", + _attribute_of_unsupported_datatype(mocker), + on_update=mocker.MagicMock(), + ) class EpicsController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) @pytest.fixture() @@ -577,9 +585,9 @@ async def do_nothing(): ... class ControllerLongNames(Controller): - attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(Int()) - attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(Int()) - attr_rw_short_name = AttrRW(Int()) + attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name = AttrR(int) + attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_rbv = AttrRW(int) + attr_rw_short_name = AttrRW(int) command_with_reallyreallyreallyreallyreallyreallyreally_long_name = Command( do_nothing ) @@ -675,10 +683,10 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): api = ControllerAPI( path=[DEVICE], attributes={ - "waveform_0d": AttrR(Waveform(np.int32, shape=())), - "waveform_1d": AttrR(Waveform(np.int32, shape=(10,))), - "waveform_2d": AttrR(Waveform(np.int32, shape=(10, 2))), - "waveform_3d": AttrR(Waveform(np.int32, shape=(10, 2, 3))), + "waveform_0d": AttrR(Array1D[np.int32], shape=()), + "waveform_1d": AttrR(Array1D[np.int32], shape=(10,)), + "waveform_2d": AttrR(Array1D[np.int32], shape=(10, 2)), + "waveform_3d": AttrR(Array1D[np.int32], shape=(10, 2, 3)), }, ) @@ -692,12 +700,12 @@ def test_non_1d_waveforms_discarded(mocker: MockerFixture): ) -def test_update_datatype(mocker: MockerFixture): +def test_update_meta(mocker: MockerFixture): builder = mocker.patch("fastcs.transports.epics.ca.util.builder") pv_name = f"{DEVICE}:Attr" - attr_r = AttrR(Int()) + attr_r = AttrR(int) record_r = _make_in_record(pv_name, attr_r) builder.longIn.assert_called_once_with( @@ -709,17 +717,17 @@ def test_update_datatype(mocker: MockerFixture): initial_value=0, ) record_r.set_field.assert_not_called() - attr_r.update_datatype(Int(units="m", min_alarm=-3)) + attr_r.update_meta(Meta(units="m", limits=NumericLimits(display=Limits(low=-3)))) record_r.set_field.assert_any_call("EGU", "m") record_r.set_field.assert_any_call("LOPR", -3) with pytest.raises( - ValueError, - match="Attribute datatype must be of type ", + TypeError, + match="'precision' is not valid metadata for int", ): - attr_r.update_datatype(String()) # type: ignore + attr_r.update_meta(Meta(precision=3)) - attr_w = AttrW(Int()) + attr_w = AttrW(int) record_w = _make_out_record(pv_name, attr_w, on_update=mocker.ANY) builder.longOut.assert_called_once_with( @@ -736,16 +744,21 @@ def test_update_datatype(mocker: MockerFixture): blocking=True, ) record_w.set_field.assert_not_called() - attr_w.update_datatype(Int(units="m", min_alarm=-1, min=-3)) + attr_w.update_meta( + Meta( + units="m", + limits=NumericLimits(display=Limits(low=-1), control=Limits(low=-3)), + ) + ) record_w.set_field.assert_any_call("EGU", "m") record_w.set_field.assert_any_call("LOPR", -1) record_w.set_field.assert_any_call("DRVL", -3) with pytest.raises( - ValueError, - match="Attribute datatype must be of type ", + TypeError, + match="'precision' is not valid metadata for int", ): - attr_w.update_datatype(String()) # type: ignore + attr_w.update_meta(Meta(precision=3)) def test_ca_context_contains_softioc_commands(mocker: MockerFixture): diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 098c3cfc6..56fe9b897 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -16,7 +16,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform +from fastcs.datatypes import Array1D, Limits, NumericLimits, Table from fastcs.launch import FastCS from fastcs.methods import command from fastcs.transports.epics.pva.transport import EpicsPVATransport @@ -224,8 +224,17 @@ def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: def test_read_signal_set(): class SomeController(Controller): - a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - b: AttrR = AttrR(Float(min=-1, min_alarm=-0.5, prec=2)) + a: AttrRW = AttrRW( + int, + limits=NumericLimits( + control=Limits(high=400_000), alarm=Limits(high=40_000) + ), + ) + b: AttrR = AttrR( + float, + limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)), + precision=2, + ) controller = SomeController() pv_prefix = str(uuid4()) @@ -265,20 +274,29 @@ async def _wait_and_set_attr_r(): def test_pvi_grouping(): class ChildChildController(Controller): - attr_e: AttrRW = AttrRW(Int()) - attr_f: AttrR = AttrR(String()) + attr_e: AttrRW = AttrRW(int) + attr_f: AttrR = AttrR(str) class ChildController(Controller): - attr_c: AttrW = AttrW(Bool(), description="Some bool") - attr_d: AttrW = AttrW(String()) + attr_c: AttrW = AttrW(bool, description="Some bool") + attr_d: AttrW = AttrW(str) class SomeController(Controller): description = "some controller" - attr_1: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - attr_1: AttrRW = AttrRW(Float(min=-1, min_alarm=-0.5, prec=2)) - another_attr_0: AttrRW = AttrRW(Int()) - another_attr_1000: AttrRW = AttrRW(Int()) - a_third_attr: AttrW = AttrW(Int()) + attr_1: AttrRW = AttrRW( + int, + limits=NumericLimits( + control=Limits(high=400_000), alarm=Limits(high=40_000) + ), + ) + attr_1: AttrRW = AttrRW( + float, + limits=NumericLimits(control=Limits(low=-1), alarm=Limits(low=-0.5)), + precision=2, + ) + another_attr_0: AttrRW = AttrRW(int) + another_attr_1000: AttrRW = AttrRW(int) + a_third_attr: AttrW = AttrW(int) controller = SomeController() @@ -415,9 +433,9 @@ class AnEnum(enum.Enum): C = 3 class SomeController(Controller): - some_waveform: AttrRW = AttrRW(Waveform(np.int64, shape=(10, 10))) - some_table: AttrRW = AttrRW(Table(table_columns)) - some_enum: AttrRW = AttrRW(Enum(AnEnum)) + some_waveform: AttrRW = AttrRW(Array1D[np.int64], shape=(10, 10)) + some_table: AttrRW = AttrRW(Table, structured_dtype=table_columns) + some_enum: AttrRW = AttrRW(AnEnum) controller = SomeController() pv_prefix = str(uuid4()) @@ -525,12 +543,7 @@ async def _wait_and_put_pvs(): ] for expected_enum, actual_enum in zip(expected_enum_gets, enum_values, strict=True): - assert ( - expected_enum - == controller.some_enum.datatype.members[ # type: ignore - actual_enum.todict()["value"]["index"] - ] - ) + assert expected_enum == list(AnEnum)[actual_enum.todict()["value"]["index"]] def test_command_method_put_twice(caplog): @@ -680,7 +693,7 @@ async def test_setpoint_seeded_by_initial_poll_reaches_transport( class SeedController(Controller): def __init__(self): super().__init__() - self.a = AttrRW(Int(), getter=self.get_a) + self.a = AttrRW(int, getter=self.get_a) async def get_a(self) -> int: return 10 diff --git a/tests/transports/epics/pva/test_pva_gui.py b/tests/transports/epics/pva/test_pva_gui.py index 4a753608e..6870110b0 100644 --- a/tests/transports/epics/pva/test_pva_gui.py +++ b/tests/transports/epics/pva/test_pva_gui.py @@ -4,6 +4,7 @@ LED, ButtonPanel, CheckBox, + ImageColorMap, ImageRead, SignalR, SignalW, @@ -17,22 +18,24 @@ from fastcs.attributes import AttrR, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Table, Waveform -from fastcs.transports.epics.gui import EpicsGUI +from fastcs.datatypes import Table from fastcs.transports.epics.pva.gui import PvaEpicsGUI @pytest.mark.parametrize( - "datatype, widget", + "attribute, widget", [ - (Waveform(array_dtype=np.int32), ImageRead()), + ( + AttrR(np.ndarray, array_dtype=np.int32, shape=(10, 20)), + ImageRead(height=10, width=20, color_map=ImageColorMap.GRAY), + ), ], ) -def test_pva_get_attribute_component_r(datatype, widget): - gui = EpicsGUI(ControllerAPI()) +def test_pva_get_attribute_component_r(attribute, widget): + gui = PvaEpicsGUI(ControllerAPI()) - assert gui._get_attribute_component(["DEVICE"], "Attr", AttrR(datatype)) == SignalR( - name="Attr", read_pv="DEVICE:Attr", read_widget=widget + assert gui._get_attribute_component(["DEVICE"], "Attr", attribute) == SignalR( + name="Attr", read_pv="pva://DEVICE:Attr", read_widget=widget ) @@ -51,13 +54,12 @@ def test_get_attribute_component_table_write(): ["DEVICE"], "Table", AttrW( - Table( - structured_dtype=[ - ("FIELD1", np.uint32), - ("FIELD2", np.bool), - ("FIELD3", np.dtype("S1000")), - ] - ) + Table, + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ], ), ) @@ -77,13 +79,12 @@ def test_get_attribute_component_table_read(): ["DEVICE"], "Table", AttrR( - Table( - structured_dtype=[ - ("FIELD1", np.uint32), - ("FIELD2", np.bool), - ("FIELD3", np.dtype("S1000")), - ] - ) + Table, + structured_dtype=[ + ("FIELD1", np.uint32), + ("FIELD2", np.bool), + ("FIELD3", np.dtype("S1000")), + ], ), ) diff --git a/tests/transports/epics/test_emission.py b/tests/transports/epics/test_emission.py index b4e223650..e83d0ac3f 100644 --- a/tests/transports/epics/test_emission.py +++ b/tests/transports/epics/test_emission.py @@ -6,7 +6,6 @@ from fastcs.attributes import AttrR from fastcs.controllers import Controller -from fastcs.datatypes import Int from fastcs.transports.epics.emission import ( DOCS_EXT, INDEX_STEM, @@ -24,11 +23,11 @@ class _Alpha(Controller): - foo = AttrR(Int()) + foo = AttrR(int) class _Beta(Controller): - bar = AttrR(Int()) + bar = AttrR(int) def _api_with_id(controller_class: type[Controller], name: str): diff --git a/tests/transports/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py index 193d081cd..ae618141c 100644 --- a/tests/transports/graphQL/test_graphql.py +++ b/tests/transports/graphQL/test_graphql.py @@ -12,17 +12,16 @@ ) from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Float, Int, String from fastcs.transports.graphql.transport import GraphQLTransport class GraphQLController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) _GQL_ID = "device" diff --git a/tests/transports/rest/test_rest.py b/tests/transports/rest/test_rest.py index 2f458cb05..8bb7d97cc 100644 --- a/tests/transports/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -9,20 +9,20 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controllers import ControllerAPI -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.rest.transport import RestTransport class RestController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) - two_d_waveform = AttrRW(Waveform(np.int32, (10, 10))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) + two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10)) @pytest.fixture(scope="class") @@ -97,7 +97,7 @@ def test_enum( ): enum_attr = rest_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) - enum_cls = enum_attr.datatype.dtype + enum_cls = enum_attr.dtype assert isinstance(enum_attr.readback, enum_cls) assert enum_attr.readback == enum_cls(0) expect = 0 diff --git a/tests/transports/tango/test_dsr.py b/tests/transports/tango/test_dsr.py index 1eef2c242..abbaa439a 100644 --- a/tests/transports/tango/test_dsr.py +++ b/tests/transports/tango/test_dsr.py @@ -12,7 +12,7 @@ ) from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform +from fastcs.datatypes import Array1D from fastcs.transports.tango.transport import TangoTransport @@ -30,15 +30,15 @@ def mock_run_threadsafe_blocking(module_mocker: MockerFixture): class TangoController(MyTestController): - read_int = AttrR(Int()) - read_write_int = AttrRW(Int()) - read_write_float = AttrRW(Float()) - read_bool = AttrR(Bool()) - write_bool = AttrW(Bool()) - read_string = AttrRW(String()) - enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform = AttrRW(Waveform(np.int32, (10,))) - two_d_waveform = AttrRW(Waveform(np.int32, (10, 10))) + read_int = AttrR(int) + read_write_int = AttrRW(int) + read_write_float = AttrRW(float) + read_bool = AttrR(bool) + write_bool = AttrW(bool) + read_string = AttrRW(str) + enum = AttrRW(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2})) + one_d_waveform = AttrRW(Array1D[np.int32], shape=(10,)) + two_d_waveform = AttrRW(Array1D[np.int32], shape=(10, 10)) @pytest.fixture(scope="class") @@ -148,7 +148,7 @@ def test_write_bool( def test_enum(self, tango_controller_api: AssertableControllerAPI, tango_context): enum_attr = tango_controller_api.attributes["enum"] assert isinstance(enum_attr, AttrRW) - enum_cls = enum_attr.datatype.dtype + enum_cls = enum_attr.dtype assert isinstance(enum_attr.readback, enum_cls) assert enum_attr.readback == enum_cls(0) expect = 0 From 127c5eabbe537c064d679ba0082f8046c78f6cb7 Mon Sep 17 00:00:00 2001 From: "Tom C (DLS)" <101418278+coretl@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:15:59 +0100 Subject: [PATCH 35/36] controllers: ControllerRunner, plus native timestamps and severity on attributes (#420) * controllers(#395): ControllerRunner, native timestamps and severity - `ControllerRunner` owns the controller lifecycle - initialise, connect, the initial and periodic tasks, reconnect, disconnect - with no transport or interactive-shell concerns. `FastCS.serve` becomes a caller of it. Starting is in two halves so a transport can be wired to the APIs before the first values are read; `start()` alone does both, for an embedder that does not need them in between. - The runner also owns reconnect. A scan task that raises marks its controller disconnected and pauses; until now nothing ever called `reconnect()`, so it stayed paused unless the driver wired its own recovery. - `Controller.connected` exposes the connection state that was only readable through the private `_connected`. - A value entering an attribute may carry when it was obtained and how wrong it is, via `Update(timestamp=..., severity=...)`; a bare value is stamped on arrival and reported as no alarm. `Severity` is a FastCS enum using the same strings as EPICS. `AttrR.timestamp` and `AttrR.severity` read them back. - Documents the stable interface an embedder is restricted to. The `AttrW` setpoint cache the issue also lists was already delivered by #412, as the `.setpoint` property ADR 0016 settled on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * refactor(#395): drop dead code from the new runner The `controllers` property has no caller, and `Task.cancel` does not raise - the guards `FastCS._stop_scan_tasks` wrapped it in never fired, so moving them across only moved unreachable code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G * test(#395): tighten the runner and timestamp tests Addresses review on #420. - The two "no timestamp given" tests become one parametrized over a bare value and an `Update`, with the default-severity assertions moved out of the timestamp tests into their own parametrized test rather than dropped. - `test_stop_reports_a_failing_disconnect_without_raising` now mocks the logger and asserts the disconnect exception was actually reported, which is what its name claimed. - The reconnect counters are kept on the instance rather than the class, and asserted against the controller under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LE4eLtgCbdRjrg7t17HRPa * fix: use correct datatype in test --------- Co-authored-by: Claude Co-authored-by: Shihab Suliman --- docs/explanations/stable-interface.md | 112 +++++++++++++++ src/fastcs/attributes/__init__.py | 1 + src/fastcs/attributes/attr_r.py | 34 +++++ src/fastcs/attributes/severity.py | 20 +++ src/fastcs/attributes/update.py | 7 + src/fastcs/control_system.py | 60 ++------ src/fastcs/controllers/__init__.py | 1 + src/fastcs/controllers/controller.py | 9 ++ src/fastcs/controllers/runner.py | 151 ++++++++++++++++++++ tests/test_attributes.py | 76 +++++++++- tests/test_control_system.py | 13 +- tests/test_controller_runner.py | 195 ++++++++++++++++++++++++++ tests/test_multi_controller.py | 10 +- 13 files changed, 626 insertions(+), 63 deletions(-) create mode 100644 docs/explanations/stable-interface.md create mode 100644 src/fastcs/attributes/severity.py create mode 100644 src/fastcs/controllers/runner.py create mode 100644 tests/test_controller_runner.py diff --git a/docs/explanations/stable-interface.md b/docs/explanations/stable-interface.md new file mode 100644 index 000000000..52f9c4b21 --- /dev/null +++ b/docs/explanations/stable-interface.md @@ -0,0 +1,112 @@ +# The Stable Interface + +Most of FastCS is free to change while it is pre-1.0. A narrow part of it is +not: the surface an *embedder* uses — code that runs FastCS controllers inside +another framework rather than serving them over a transport, such as the +ophyd-async connector. That surface is listed here, and an embedder should use +nothing outside it. In particular, nothing should reach into `BaseController`. + +## Running controllers: `ControllerRunner` + +`ControllerRunner` owns the controller lifecycle and nothing else — no +transports, no interactive shell. `FastCS` is a caller of it. + +```python +from fastcs.controllers import ControllerRunner + +runner = ControllerRunner(controller) +apis = await runner.setup() # initialise, and build the ControllerAPIs +await runner.start() # connect, run initial tasks, start scanning +... +await runner.stop() # stop the tasks, disconnect +``` + +- **`setup()`** runs `initialise()` and `post_initialise()` on each controller + and builds their `ControllerAPI`s. It exists as a separate step because + anything serving the controllers has to register its callbacks *before* the + first values are read, or it misses them. +- **`start()`** connects the controllers, runs the initial (`ONCE`) tasks, and + starts the periodic ones. It runs `setup()` first if you have not, so an + embedder that does not need the APIs in between can just call `start()`. +- **`stop()`** cancels the tasks and disconnects. + +**Idempotency is the caller's responsibility.** Starting a running runner, or +stopping a stopped one, is not defined — an embedder whose own connect may run +more than once has to keep track itself. + +The runner also owns **reconnect**. A scan task whose callback raises marks its +controller disconnected and pauses rather than dying; the runner notices and +calls `Controller.reconnect()` until it comes back. This is deliberately not +left to each controller, so every controller recovers the same way. + +## Reading the structure: `ControllerAPI` + +`ControllerAPI` is the read-only view of a controller: + +- `attributes` — the `Attribute`s, by name +- `command_methods` — the `Command`s, by name +- `scan_methods` — the `Scan`s, by name +- `sub_apis` — child `ControllerAPI`s, by name +- `path` and `description` +- `walk_api()` — this API and every descendant + +## Reading and writing values: the attribute surface + +For an `AttrR` (and so an `AttrRW`): + +- `readback` — the last known value +- `timestamp` — when that value was obtained, as a unix timestamp: the time the + source reported if it reported one, otherwise the time the update arrived +- `severity` — how wrong that value is, as a `Severity` +- `await poll()` — read a fresh value from the getter, cache it, return it +- `await update(value)` — push a value into the cache without any IO. Accepts a + bare value or an `Update`, which may carry a timestamp and severity +- `add_readback_callback(cb)` — be told when the readback changes + +For an `AttrW` (and so an `AttrRW`): + +- `setpoint` — the last value asked for. Cached by `set()` *before* the setter + runs and regardless of whether it succeeds, so it answers "what did we last + ask for", distinct from `readback`'s "what did we last read" +- `await set(value)` — cache the setpoint and apply it through the setter +- `add_setpoint_callback(cb)` — be told when the setpoint changes + +For any attribute: `dtype`, `access_mode`, `description`, `group`, and the +metadata it carries. + +## Calling actions: the command surface + +- `await command()` — call it +- `command.signature` — what it takes and returns + +## Timestamps and severity + +A value entering an attribute may carry when it was obtained and how wrong it +is, by arriving as an `Update`: + +```python +from fastcs.attributes import Severity, Update + +async def get_temperature() -> Update[float]: + value, device_time = await protocol.read_with_timestamp() + return Update(readback=value, timestamp=device_time) + +async def get_status() -> Update[float]: + value, fault = await protocol.read_status() + return Update( + readback=value, + severity=Severity.MAJOR if fault else Severity.NO_ALARM, + ) +``` + +A bare value is stamped with the time it arrived and reported as +`Severity.NO_ALARM`. This matters because a device that already knows when a +value was measured — an EPICS record timestamp, a Tango event — otherwise has +nowhere to say so, and the reading silently becomes "whenever FastCS heard +about it". + +`Severity` is a FastCS enum that uses the same strings as EPICS alarm +severities, so a driver or transport speaking EPICS does not have to translate. +It is not EPICS-specific. The value/timestamp/severity trio follows the shape of +bluesky's `Reading` so that the two read the same way, but shares no code with +it. diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index e968192b2..b9e08edcd 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -9,4 +9,5 @@ from .attribute import Attribute as Attribute from .attribute import AttributeAccessMode as AttributeAccessMode from .hinted_attribute import HintedAttribute as HintedAttribute +from .severity import Severity as Severity from .update import Update as Update diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py index d0df6567f..b36b6c3a5 100644 --- a/src/fastcs/attributes/attr_r.py +++ b/src/fastcs/attributes/attr_r.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio +import time from collections.abc import Awaitable, Callable, Coroutine from dataclasses import KW_ONLY, dataclass, replace from typing import Any, Generic, Unpack, overload from fastcs.attributes._infer_datatype import infer_datatype_from_getter from fastcs.attributes.attribute import Attribute, AttributeAccessMode +from fastcs.attributes.severity import Severity from fastcs.attributes.update import Update from fastcs.attributes.util import AttrValuePredicate, PredicateEvent from fastcs.datatypes import ( @@ -192,6 +194,11 @@ def __init__( self._value: DType_T = ( self.default_value() if initial_value is None else initial_value ) + self._timestamp: float = time.time() + """When the cached value was obtained, or when the attribute was created""" + self._severity: Severity = Severity.NO_ALARM + """How wrong the cached value is, as last reported""" + self._getter = resolved_getter self._getter: Getter[DType_T] | None = resolved_getter self._poll_period: float | None = poll_period """Period in seconds between calls to poll(), or ONCE, or None (on-demand)""" @@ -207,6 +214,20 @@ def readback(self) -> DType_T: """The last known value of the attribute.""" return self._value + @property + def timestamp(self) -> float: + """When the last known value was obtained, as a unix timestamp. + + The time the source reported, if it reported one, and otherwise the + time the update reached FastCS. + """ + return self._timestamp + + @property + def severity(self) -> Severity: + """How wrong the last known value is, as the source last reported.""" + return self._severity + def has_getter(self) -> bool: return self._getter is not None @@ -231,6 +252,10 @@ async def update(self, value: DType_T | Update[DType_T]) -> None: To request a change to the setpoint of the attribute, use the ``set`` method, which will attempt to apply the change to the underlying source. + A value that arrives as an ``Update`` may carry the time it was obtained + and how wrong it is; a bare value is stamped with the time it arrived and + reported as ``Severity.NO_ALARM``. + Args: value: The new value of the attribute, or an ``Update`` wrapping it @@ -238,8 +263,13 @@ async def update(self, value: DType_T | Update[DType_T]) -> None: ValueError: If the value fails to be validated to DType_T """ + received_at = time.time() if isinstance(value, Update): + timestamp = received_at if value.timestamp is None else value.timestamp + severity = Severity.NO_ALARM if value.severity is None else value.severity value = value.readback + else: + timestamp, severity = received_at, Severity.NO_ALARM self.log_event("Attribute set", value=repr(value), attribute=self) @@ -250,6 +280,10 @@ async def update(self, value: DType_T | Update[DType_T]) -> None: logger.error("Failed to validate value", value=repr(value), attribute=self) raise + # Only once the value is known good, so a rejected update leaves the + # cached value and the time it was obtained agreeing with each other. + self._timestamp, self._severity = timestamp, severity + self.log_event("Value validated", value=repr(self._value), attribute=self) self._on_update_events -= { diff --git a/src/fastcs/attributes/severity.py b/src/fastcs/attributes/severity.py new file mode 100644 index 000000000..cbead56b7 --- /dev/null +++ b/src/fastcs/attributes/severity.py @@ -0,0 +1,20 @@ +from enum import Enum + + +class Severity(Enum): + """How wrong a value is, if at all. + + A FastCS-native enum that happens to use the same strings as EPICS alarm + severities, so a driver or transport speaking EPICS does not have to + translate. It is not EPICS-specific: a Tango event push or any other IO can + report severity the same way, through `Update`. + """ + + NO_ALARM = "NO_ALARM" + """The value is good""" + MINOR = "MINOR" + """The value is outside its warning range, or the device reports a minor fault""" + MAJOR = "MAJOR" + """The value is outside its alarm range, or the device reports a major fault""" + INVALID = "INVALID" + """The value could not be read, or cannot be trusted""" diff --git a/src/fastcs/attributes/update.py b/src/fastcs/attributes/update.py index be84f4be2..dde4addb4 100644 --- a/src/fastcs/attributes/update.py +++ b/src/fastcs/attributes/update.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Generic +from fastcs.attributes.severity import Severity from fastcs.datatypes import DType_T @@ -15,14 +16,20 @@ class Update(Generic[DType_T]): - ``timestamp`` - when the value was obtained. ``None`` means the framework should stamp it with the time the update was received. + - ``severity`` - how wrong the value is, if the source says. ``None`` means + the source did not report one, which is read as ``Severity.NO_ALARM``. - ``setpoint`` - a setpoint to publish alongside the readback. ``None`` leaves the cached setpoint untouched. A bare value returned from a setter is equivalent to ``Update(readback=value, setpoint=value)`` - the device's accepted or clamped value, which is both what it will report and what was asked of it. + + The value/timestamp/severity trio follows the shape of bluesky's ``Reading`` + so the two read the same way, but shares no code with it. """ readback: DType_T timestamp: float | None = None setpoint: DType_T | None = None + severity: Severity | None = None diff --git a/src/fastcs/control_system.py b/src/fastcs/control_system.py index e8604d703..44467fe63 100644 --- a/src/fastcs/control_system.py +++ b/src/fastcs/control_system.py @@ -7,9 +7,8 @@ from IPython.terminal.embed import InteractiveShellEmbed -from fastcs.controllers import Controller, ControllerAPI +from fastcs.controllers import Controller, ControllerAPI, ControllerRunner from fastcs.logging import logger -from fastcs.methods import ScanCallback from fastcs.tracer import Tracer from fastcs.transports import Transport @@ -62,10 +61,7 @@ def __init__( self._transports = transports self._loop = loop or asyncio.get_event_loop() - self._scan_coros: list[ScanCallback] = [] - self._initial_coros: list[ScanCallback] = [] - - self._scan_tasks: set[asyncio.Task] = set() + self._runner = ControllerRunner(self._controllers, self._loop) self.controller_apis: list[ControllerAPI] = [] def run(self, interactive: bool = True): @@ -84,25 +80,6 @@ def run(self, interactive: bool = True): self._loop.add_signal_handler(signal.SIGTERM, serve.cancel) self._loop.run_until_complete(serve) - async def _run_initial_coros(self): - for coro in self._initial_coros: - await coro() - - async def _start_scan_tasks(self): - self._scan_tasks = {self._loop.create_task(coro()) for coro in self._scan_coros} - - def _stop_scan_tasks(self): - for task in self._scan_tasks: - if not task.done(): - try: - task.cancel() - except (asyncio.CancelledError, RuntimeError): - pass - except Exception as e: - raise RuntimeError("Unhandled exception in stop scan tasks") from e - - self._scan_tasks.clear() - async def serve(self, interactive: bool = True) -> None: """Serve the control system over the given transports on the current event loop @@ -118,18 +95,10 @@ async def serve(self, interactive: bool = True) -> None: interactive: Whether to create an interactive IPython shell """ - for controller in self._controllers: - await controller.initialise() - controller.post_initialise() - - self.controller_apis = [] - self._scan_coros = [] - self._initial_coros = [] - for controller in self._controllers: - api, scan_coros, initial_coros = controller.create_api_and_tasks() - self.controller_apis.append(api) - self._scan_coros.extend(scan_coros) - self._initial_coros.extend(initial_coros) + # Build the APIs before wiring transports to them: a transport + # registers its callbacks when it connects, and would miss the first + # readback if the controllers had already started. + self.controller_apis = await self._runner.setup() context = { "controllers": {_context_key(c): c for c in self._controllers}, @@ -172,10 +141,7 @@ async def block_forever(): transports=f"[{', '.join(str(t) for t in self._transports)}]", ) - for controller in self._controllers: - await controller.connect() - await self._run_initial_coros() - await self._start_scan_tasks() + await self._runner.start() try: await asyncio.gather(*coros) @@ -185,15 +151,7 @@ async def block_forever(): logger.exception("Unhandled exception in serve") finally: logger.info("Shutting down FastCS") - self._stop_scan_tasks() - for controller in self._controllers: - try: - await controller.disconnect() - except Exception: - logger.exception( - "Exception during disconnect", - controller=_context_key(controller), - ) + await self._runner.stop() async def _interactive_shell(self, context: dict[str, Any]): """Spawn interactive shell in another thread and wait for it to complete.""" @@ -222,4 +180,4 @@ async def interactive_shell( await stop_event.wait() def __del__(self): - self._stop_scan_tasks() + self._runner._cancel_tasks() # noqa: SLF001 diff --git a/src/fastcs/controllers/__init__.py b/src/fastcs/controllers/__init__.py index b982292de..e3fe4106e 100644 --- a/src/fastcs/controllers/__init__.py +++ b/src/fastcs/controllers/__init__.py @@ -2,3 +2,4 @@ from .controller import Controller as Controller from .controller_api import ControllerAPI as ControllerAPI from .controller_vector import ControllerVector as ControllerVector +from .runner import ControllerRunner as ControllerRunner diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py index b03793db6..0bee8d7d8 100755 --- a/src/fastcs/controllers/controller.py +++ b/src/fastcs/controllers/controller.py @@ -28,6 +28,15 @@ def add_sub_controller(self, name: str, sub_controller: BaseController): ) return super().add_sub_controller(name, sub_controller) + @property + def connected(self) -> bool: + """Whether the controller believes it can talk to its device. + + Set by `connect`/`reconnect`, and cleared when a scan task raises. The + `ControllerRunner` reads it to decide when to reconnect. + """ + return self._connected + async def connect(self) -> None: """Hook to perform initial connection to device diff --git a/src/fastcs/controllers/runner.py b/src/fastcs/controllers/runner.py new file mode 100644 index 000000000..ead653e9e --- /dev/null +++ b/src/fastcs/controllers/runner.py @@ -0,0 +1,151 @@ +import asyncio +from collections.abc import Sequence + +from fastcs.controllers.controller import Controller +from fastcs.controllers.controller_api import ControllerAPI +from fastcs.logging import logger +from fastcs.methods import ScanCallback + +RECONNECT_PERIOD = 1.0 +"""Seconds between checks for a controller that has dropped its connection""" + + +class ControllerRunner: + """Runs one or more `Controller` s, without serving them anywhere. + + This owns the whole controller lifecycle - initialising, connecting, + running the initial and periodic tasks, reconnecting after a failure, and + tidying up - and nothing about how the controllers are presented. `FastCS` + uses it and adds transports on top; an embedded caller that only wants the + controllers running can use it on its own:: + + runner = ControllerRunner(controller) + await runner.start() + ... + await runner.stop() + + Starting has two halves, because anything serving the controllers needs + their `ControllerAPI` before the first values are read: ``setup`` initialises + them and builds the APIs, and ``start`` connects and starts the tasks. + Calling ``start`` on its own does both. + + **Idempotency is the caller's responsibility.** Starting a running runner, + or stopping a stopped one, is not defined. + + Args: + controllers: The controller(s) to run. Accepts either a single + ``Controller`` or a sequence of them. + loop: Optional event loop to create the tasks in + + """ + + def __init__( + self, + controllers: Controller | Sequence[Controller], + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + if isinstance(controllers, Controller): + controllers = [controllers] + self._controllers: list[Controller] = list(controllers) + self._loop = loop + + self._controller_apis: list[ControllerAPI] = [] + self._scan_coros: list[ScanCallback] = [] + self._initial_coros: list[ScanCallback] = [] + self._tasks: set[asyncio.Task] = set() + + @property + def controller_apis(self) -> list[ControllerAPI]: + """The API of each controller. Empty until ``setup`` has run.""" + return self._controller_apis + + async def setup(self) -> list[ControllerAPI]: + """Initialise the controllers and build their APIs. + + Runs before anything connects, so that a transport can be wired to the + APIs and catch the first readback. + + Returns: + The API of each controller, in the order they were given + + """ + for controller in self._controllers: + await controller.initialise() + controller.post_initialise() + + self._controller_apis = [] + self._scan_coros = [] + self._initial_coros = [] + for controller in self._controllers: + api, scan_coros, initial_coros = controller.create_api_and_tasks() + self._controller_apis.append(api) + self._scan_coros.extend(scan_coros) + self._initial_coros.extend(initial_coros) + + return self._controller_apis + + async def start(self) -> None: + """Connect the controllers and start their tasks. + + Runs ``setup`` first if it has not already run. + """ + if not self._controller_apis: + await self.setup() + + for controller in self._controllers: + await controller.connect() + + for coro in self._initial_coros: + await coro() + + loop = self._loop or asyncio.get_event_loop() + self._tasks = {loop.create_task(coro()) for coro in self._scan_coros} + self._tasks |= { + loop.create_task(self._reconnect_loop(controller)) + for controller in self._controllers + } + + async def stop(self) -> None: + """Stop the tasks and disconnect the controllers.""" + self._cancel_tasks() + + for controller in self._controllers: + try: + await controller.disconnect() + except Exception: + logger.exception( + "Exception during disconnect", controller=controller.path + ) + + async def _reconnect_loop(self, controller: Controller) -> None: + """Bring a controller back after its scan tasks hit an error. + + A scan task that raises marks its controller disconnected and pauses + rather than dying, so something has to try to bring it back. That is the + runner's job rather than the controller's, so that every controller + reconnects the same way whether or not its author thought about it. + """ + while True: + await asyncio.sleep(RECONNECT_PERIOD) + + if controller.connected: + continue + + logger.info("Attempting to reconnect", controller=controller.path) + try: + await controller.reconnect() + except Exception: + logger.exception("Reconnect failed", controller=controller.path) + + def _cancel_tasks(self) -> None: + # ``Task.cancel`` does not raise - it returns whether the task was + # cancellable - so the guards the old FastCS._stop_scan_tasks wrapped + # this in never fired. + for task in self._tasks: + if not task.done(): + task.cancel() + + self._tasks.clear() + + def __del__(self): + self._cancel_tasks() diff --git a/tests/test_attributes.py b/tests/test_attributes.py index a4d25f4c0..0a0b0d061 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,4 +1,5 @@ import asyncio +import time from functools import partial import numpy as np @@ -6,7 +7,15 @@ import pytest from pytest_mock import MockerFixture -from fastcs.attributes import AttrR, AttrRW, AttrW, NotPolled, Polled, Update +from fastcs.attributes import ( + AttrR, + AttrRW, + AttrW, + NotPolled, + Polled, + Severity, + Update, +) from fastcs.controllers import Controller from fastcs.datatypes import ( DEFAULT_ARRAY_SHAPE, @@ -454,6 +463,71 @@ async def setter(value, uri=uri): assert c.int_parameter.readback == 20 +@pytest.mark.parametrize("value", [3, Update(readback=3)], ids=["bare", "Update"]) +@pytest.mark.asyncio +async def test_a_value_with_no_timestamp_is_stamped_when_it_arrived(value): + attr = AttrR(int) + before = time.time() + + await attr.update(value) + + assert before <= attr.timestamp <= time.time() + + +@pytest.mark.parametrize("value", [3, Update(readback=3)], ids=["bare", "Update"]) +@pytest.mark.asyncio +async def test_a_value_with_no_severity_is_reported_as_no_alarm(value): + attr = AttrR(int) + + await attr.update(value) + + assert attr.severity is Severity.NO_ALARM + + +@pytest.mark.asyncio +async def test_an_update_can_carry_the_time_the_value_was_obtained(): + attr = AttrR(int) + + await attr.update(Update(readback=3, timestamp=1234.5)) + + assert attr.timestamp == 1234.5 + + +@pytest.mark.asyncio +async def test_an_update_can_carry_a_severity(): + attr = AttrR(int) + + await attr.update(Update(readback=3, severity=Severity.MAJOR)) + + assert attr.severity is Severity.MAJOR + + +@pytest.mark.asyncio +async def test_a_getter_can_report_a_timestamp_and_severity(): + async def get_value() -> Update[int]: + return Update(readback=7, timestamp=99.0, severity=Severity.MINOR) + + attr = AttrR(int, getter=get_value) + + assert await attr.poll() == 7 + assert attr.timestamp == 99.0 + assert attr.severity is Severity.MINOR + + +@pytest.mark.asyncio +async def test_a_rejected_value_leaves_the_timestamp_alone(): + """The cached value and the time it was obtained must agree.""" + attr = AttrRW(int, limits=NumericLimits(control=Limits(low=0)), getter=None) + + await attr.update(Update(readback=1, timestamp=10.0)) + + with pytest.raises(ValueError): + await attr.update(Update(readback=-1, timestamp=20.0)) + + assert attr.readback == 1 + assert attr.timestamp == 10.0 + + def test_metadata_is_held_on_the_attribute(): attr = AttrRW(float, precision=3, units="degC", description="the temperature") diff --git a/tests/test_control_system.py b/tests/test_control_system.py index 77b48e20a..19e3215e7 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -94,18 +94,19 @@ def __init__(self): assert controller.update_once.readback == 1 assert controller.update_never.readback == 0 - assert len(fastcs._scan_tasks) == 1 - assert len(fastcs._initial_coros) == 1 + # One periodic scan task per distinct period, plus one reconnect watcher + assert len(fastcs._runner._scan_coros) == 1 + assert len(fastcs._runner._initial_coros) == 1 @pytest.mark.asyncio async def test_controller_connect_disconnect(): class MyTestController(Controller): async def connect(self): - self.connected = True + self.connect_called = True async def disconnect(self): - self.connected = False + self.connect_called = False controller = MyTestController() @@ -116,10 +117,10 @@ async def disconnect(self): # connect is called at the start of serve await asyncio.sleep(0.1) - assert controller.connected + assert controller.connect_called task.cancel() # disconnect is called at the end of serve await asyncio.sleep(0.1) - assert not controller.connected + assert not controller.connect_called diff --git a/tests/test_controller_runner.py b/tests/test_controller_runner.py new file mode 100644 index 000000000..b60bb7a64 --- /dev/null +++ b/tests/test_controller_runner.py @@ -0,0 +1,195 @@ +import asyncio +import sys + +import pytest + +from fastcs.attributes import AttrR +from fastcs.controllers import Controller, ControllerRunner +from fastcs.controllers.runner import RECONNECT_PERIOD +from fastcs.methods import scan +from fastcs.util import ONCE + + +class LifecycleController(Controller): + """Records every lifecycle hook the runner is supposed to call.""" + + def __init__(self): + super().__init__() + self.events: list[str] = [] + self.count = AttrR(int) + + async def initialise(self): + self.events.append("initialise") + + def post_initialise(self): + self.events.append("post_initialise") + + async def connect(self): + self.events.append("connect") + await super().connect() + + async def disconnect(self): + self.events.append("disconnect") + + @scan(ONCE) + async def read_once(self): + self.events.append("initial") + await self.count.update(self.count.readback + 1) + + +@pytest.mark.asyncio +async def test_the_runner_drives_the_whole_lifecycle(): + controller = LifecycleController() + runner = ControllerRunner(controller) + + await runner.start() + try: + assert controller.events == [ + "initialise", + "post_initialise", + "connect", + "initial", + ] + assert controller.count.readback == 1 + finally: + await runner.stop() + + assert controller.events[-1] == "disconnect" + + +@pytest.mark.asyncio +async def test_setup_builds_the_apis_before_anything_connects(): + """A transport is wired to the APIs between setup and start.""" + controller = LifecycleController() + runner = ControllerRunner(controller) + + apis = await runner.setup() + + assert [api.path for api in apis] == [[]] + assert "count" in apis[0].attributes + assert controller.events == ["initialise", "post_initialise"] + assert runner.controller_apis == apis + + +@pytest.mark.asyncio +async def test_start_sets_up_when_setup_has_not_run(): + runner = ControllerRunner(LifecycleController()) + + await runner.start() + try: + assert len(runner.controller_apis) == 1 + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_a_runner_takes_several_controllers(): + controllers = [LifecycleController(), LifecycleController()] + runner = ControllerRunner(controllers) + + await runner.start() + try: + assert len(runner.controller_apis) == 2 + assert all(controller.count.readback == 1 for controller in controllers) + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_stop_reports_a_failing_disconnect_without_raising(monkeypatch): + class UndisconnectableController(LifecycleController): + async def disconnect(self): + raise RuntimeError("no") + + logged: list[tuple[str, BaseException | None]] = [] + + def record_exception(event, **kwargs): + # ``logger.exception`` is called from the ``except`` block, so the + # exception it is reporting is the one currently being handled. + logged.append((event, sys.exc_info()[1])) + + monkeypatch.setattr("fastcs.controllers.runner.logger.exception", record_exception) + + runner = ControllerRunner(UndisconnectableController()) + await runner.start() + + await runner.stop() + + assert len(logged) == 1 + event, error = logged[0] + assert event == "Exception during disconnect" + assert isinstance(error, RuntimeError) + assert str(error) == "no" + + +@pytest.mark.asyncio +async def test_the_runner_reconnects_a_controller_that_dropped_out(monkeypatch): + """Nothing else calls reconnect, so a paused controller would stay paused.""" + monkeypatch.setattr("fastcs.controllers.runner.RECONNECT_PERIOD", 0.01) + + class DroppingController(LifecycleController): + reconnects = 0 + + async def reconnect(self): + self.reconnects += 1 + await super().reconnect() + + controller = DroppingController() + runner = ControllerRunner(controller) + await runner.start() + try: + assert controller.connected + + # What a scan task does when its callback raises + controller._connected = False + + await asyncio.sleep(0.05) + + assert controller.reconnects >= 1 + assert controller.connected + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_a_failing_reconnect_does_not_stop_the_runner(monkeypatch): + monkeypatch.setattr("fastcs.controllers.runner.RECONNECT_PERIOD", 0.01) + + class UnreconnectableController(LifecycleController): + attempts = 0 + + async def reconnect(self): + self.attempts += 1 + raise RuntimeError("still down") + + controller = UnreconnectableController() + runner = ControllerRunner(controller) + await runner.start() + try: + controller._connected = False + await asyncio.sleep(0.05) + + # It keeps trying rather than dying on the first failure + assert controller.attempts > 1 + assert not controller.connected + finally: + await runner.stop() + + +@pytest.mark.asyncio +async def test_stop_cancels_the_tasks(): + controller = LifecycleController() + runner = ControllerRunner(controller) + await runner.start() + tasks = set(runner._tasks) + assert tasks + + await runner.stop() + await asyncio.sleep(0) + + assert all(task.cancelled() or task.done() for task in tasks) + assert not runner._tasks + + +def test_reconnect_period_is_a_second_by_default(): + assert RECONNECT_PERIOD == 1.0 diff --git a/tests/test_multi_controller.py b/tests/test_multi_controller.py index 0ae223c17..1edf30f72 100644 --- a/tests/test_multi_controller.py +++ b/tests/test_multi_controller.py @@ -306,7 +306,7 @@ class _LifecycleController(Controller): def __init__(self): super().__init__() - self.connected = False + self.connect_called = False self.initialised = False self.post_initialised = False @@ -317,10 +317,10 @@ def post_initialise(self): self.post_initialised = True async def connect(self): - self.connected = True + self.connect_called = True async def disconnect(self): - self.connected = False + self.connect_called = False class _OtherLifecycleController(_LifecycleController): @@ -349,7 +349,7 @@ async def test_fastcs_serves_two_controllers_end_to_end(mocker: MockerFixture): for controller in (a, b): assert controller.initialised assert controller.post_initialised - assert controller.connected + assert controller.connect_called with TestClient(transport._server._app) as client: assert client.get("/alpha/foo").status_code == 200 @@ -369,4 +369,4 @@ async def test_fastcs_serves_two_controllers_end_to_end(mocker: MockerFixture): pass for controller in (a, b): - assert not controller.connected + assert not controller.connect_called From fc7468985889fbb5cc334c834fb3a1ea47c08736 Mon Sep 17 00:00:00 2001 From: "Tom C (DLS)" <101418278+coretl@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:57:45 +0100 Subject: [PATCH 36/36] attributes: `@attr` decorator sugar over the getter/setter constructors (#423) * attributes: @attr decorator sugar over the getter/setter constructors Adds the `@attr` decorator from ADR 0018: a controller declares an attribute by decorating the method that reads it, with `@x.setter` for the writer half, mirroring `@property`. The datatype comes from the getter's return annotation (unwrapping `Update[T]`), the getter's docstring summary becomes the description, and decorator keyword arguments are the attribute's metadata, validated against the datatype. The optional leading positional is a `Polled`/`NotPolled` schedule, so the declarative and procedural spellings share one vocabulary; a bare `@attr` is read once at connect, as a bare `getter=` is. Binding follows `@command`/`@scan`: the class body holds an `UnboundAttr` declaration and each controller instance binds a fresh `AttrR`/`AttrRW` of its own, so nothing is deepcopied from a class-scope prototype. `UnboundAttr` is a non-data descriptor so that a decorated attribute reads as the attribute it becomes rather than the declaration - `UnboundAttrRW` carries the `AttrRW` typing. Adds the "FastCS for PyTango users" docs page. Closes #397 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SiGhLM9QRKpnQykdmMfLsh * chore: amend helper function to not do the job of the caller in raising specific exceptions; instead, reraise. * tests: fix code smells in tests such as useless lines and more than one line in pytest.raises * chore: add claude.md file with issues found in this branch --------- Co-authored-by: Claude Co-authored-by: Shihab Suliman --- claude.md | 117 +++++++ docs/how-to/fastcs-for-pytango-users.md | 157 +++++++++ src/fastcs/attributes/__init__.py | 5 + src/fastcs/attributes/attr_decorator.py | 356 +++++++++++++++++++ src/fastcs/controllers/base_controller.py | 13 +- tests/test_attr_decorator.py | 399 ++++++++++++++++++++++ 6 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 claude.md create mode 100644 docs/how-to/fastcs-for-pytango-users.md create mode 100644 src/fastcs/attributes/attr_decorator.py create mode 100644 tests/test_attr_decorator.py diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..65c11785b --- /dev/null +++ b/claude.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +Conventions for this repository. Follow these when writing or reviewing code and tests. + +## Helper functions and exceptions + +- A helper function must **not** be passed a parameter (e.g. `kind`) whose only purpose + is to be interpolated into the message of an exception it raises. That parameter + exists purely to serve one or more call-sites' error-reporting needs, which means the + helper is doing the call-site's job for it. +- If different call-sites need different exceptions raised (different types and/or + different messages), do not thread a parameter into the helper to cover every case. + Instead, let the helper raise its own plain/generic exception with no caller-supplied + wording, and have each call-site `catch` it and `raise ... from ...` with the + type/message it actually needs. + + ```python + # Bad: helper takes `kind` purely to phrase its own exception message + def _check_positive(value: float, kind: str) -> None: + if value <= 0: + raise ValueError(f"{kind} must be positive, got {value}") + + _check_positive(period, kind="period") + + # Good: helper raises a plain exception; call-site adds whatever context it needs + def _check_positive(value: float) -> None: + if value <= 0: + raise ValueError(f"must be positive, got {value}") + + try: + _check_positive(period) + except ValueError as e: + raise ConfigError(f"invalid period in trigger config: {e}") from e + ``` + +- A helper function must **not** be passed a parameter like `expected` or `skip` that + tells it about the *arity or shape of the call-site* (e.g. "how many items did you + expect", "should this check be skipped"). Parameters like these are a sign that the + check itself belongs in the caller, not the helper. Move the check up: + + ```python + # Bad: helper is making a decision that belongs to the caller + def _check_length(items, expected=None): + if expected is not None and len(items) != expected: + raise ValueError(...) + + # Good: caller owns the decision, helper just does the one thing it's for + if len(items) != expected: + raise ValueError(...) + _check_length(items) + ``` + + Rule of thumb: a helper's parameters should describe *what it's being asked to + validate/produce*, never *whether/how the caller wants it validated*. + +## Tests: `pytest.raises` + +- The `with pytest.raises(...):` block should contain the **minimal code that raises + the exception** — ideally a single line, and ideally just the call under test. +- Any setup needed to *put the system in a state* where that call will raise must + happen **outside** and **before** the `pytest.raises` block, not inside it. + + ```python + # Bad: setup is inside the raises block + with pytest.raises(ValueError): + controller = Device() + controller.configure(bad_value) + + # Good: setup happens first, only the failing call is inside the block + controller = Device() + with pytest.raises(ValueError): + controller.configure(bad_value) + ``` + + This keeps the assertion precise: if setup itself started raising unexpectedly, the + test should fail with an ordinary traceback, not be masked as a (possibly + coincidental) pass inside `pytest.raises`. + +## Tests: no irrelevant lines + +- Every line in a test should be there because it affects the test's outcome, given + what the test's name says it's checking. If removing a line wouldn't change whether + the test passes or fails, it doesn't belong. +- Before adding or keeping a line in a test, check it against the test name: does this + line change the behavior being verified? If not, delete it. + + ```python + # Bad: post_initialise() doesn't affect this test's assertion + def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + controller.post_initialise() # irrelevant — remove + + assert isinstance(controller.label, AttrR) + + # Good + def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + + assert isinstance(controller.label, AttrR) + ``` + + This keeps tests readable as documentation: every line is evidence for the claim in + the test's name, not incidental noise carried over from copy-pasting another test. diff --git a/docs/how-to/fastcs-for-pytango-users.md b/docs/how-to/fastcs-for-pytango-users.md new file mode 100644 index 000000000..1f82ac09c --- /dev/null +++ b/docs/how-to/fastcs-for-pytango-users.md @@ -0,0 +1,157 @@ +# FastCS for PyTango Users + +If you write Tango Device Servers with PyTango, the shape of a FastCS controller +will already be familiar: a class, some attributes, some commands. This page +pairs the PyTango spelling with the FastCS one, so you can carry what you know +across. + +The headline difference is that a FastCS controller is not tied to Tango. The +same class is served over Tango, EPICS (Channel Access or PV Access), REST and +GraphQL - see [](./multiple-transports.md). + +## Hello world + +PyTango's simplest attribute is one decorated getter: + +```python +from tango.server import Device, attribute + + +class PowerSupply(Device): + @attribute + def voltage(self) -> float: + return 2.5 +``` + +FastCS says the same thing with `@attr`: + +```python +from fastcs.attributes import attr +from fastcs.controllers import Controller + + +class PowerSupply(Controller): + @attr + async def voltage(self) -> float: + return 2.5 +``` + +Two differences to notice: + +- The getter is `async`. FastCS controllers run on one event loop, so a getter + that talks to a device awaits it rather than blocking every other attribute. +- The datatype comes from the return annotation. There is no `dtype=` keyword to + keep in step with the code - `-> float` is one real annotation, checked by your + type checker as well as by FastCS. + +## Writing as well as reading + +PyTango pairs a getter with a `@x.write` method (or `@x.setter` in the +`attribute` decorator form). FastCS mirrors `@property`: + +```python +class PowerSupply(Controller): + @attr(units="V", precision=3) + async def voltage(self) -> float: + """Output voltage.""" + return float(await self._conn.query("V?")) + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") +``` + +A getter alone gives you a read-only `AttrR`; adding a setter makes the same +name an `AttrRW`. There is no write-only decorator - a write-only attribute is +rare enough to be written longhand as `AttrW(setter=...)`. + +The getter's docstring becomes the attribute's description, and keyword +arguments to `@attr` are the attribute's metadata - `units`, `precision`, +`limits`, `group`, `description`. They are checked against the datatype the +getter returns, so `precision` on a `-> str` getter is an error rather than a +field that is silently ignored. + +:::{note} +Type checkers special-case the builtin `property` but not decorators that +imitate it, so pyright reports the getter as *obscured by a declaration of the +same name*, and mypy as *already defined*. The two declarations are deliberate, +so silence it at the getter: + +```python +@attr(units="V") +async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + ... +``` + +Only read-write attributes need this. A read-only `@attr` declares its name +once and needs nothing. +::: + +## Deciding when a value is read + +PyTango polls an attribute on a period configured per device, outside the code. +In FastCS the schedule is part of the declaration, and is the same +`Polled`/`NotPolled` vocabulary the procedural form uses: + +```python +from fastcs.attributes import NotPolled, Polled, attr + + +class PowerSupply(Controller): + @attr(Polled(period=0.5), units="V") + async def voltage(self) -> float: + """Read every half second, because the device changes it.""" + return float(await self._conn.query("V?")) + + @attr + async def serial_number(self) -> str: + """Read once, when the controller connects.""" + return await self._conn.query("*IDN?") + + @attr(NotPolled()) + async def last_error(self) -> str: + """Never read on a schedule - only when something asks for it.""" + return await self._conn.query("ERR?") +``` + +A bare `@attr` means read once, at connect - the same default a bare +`getter=` has. See [](./update-attributes-from-device.md) for the whole picture, +including devices that push values at you rather than being polled. + +## Commands + +PyTango's `@command` and FastCS's `@command` line up directly, including typed +arguments and return values: + +```python +from fastcs.methods import command + + +class PowerSupply(Controller): + @command() + async def reset(self) -> None: + """Return the supply to its power-on state.""" + await self._conn.send("*RST") +``` + +See [](./typed-commands.md) for arguments and return values, and which +transports can serve them. + +## When not to use `@attr` + +`@attr` is the simple case: one attribute, one device call, known at the time +you write the class. It is sugar over the procedural form, and there are two +other spellings for when it stops fitting: + +- **The attribute needs more than a getter and a setter** - a shared connection + object, several attributes built in a loop, values that come from one + request - build them in `__init__` with `AttrR(getter=...)` / + `AttrRW(getter=..., setter=...)` directly. `@attr` degrades into exactly that + form, so nothing is lost by moving. +- **The device describes itself** - the attributes are discovered by asking the + device what it has, rather than written out. Declare what your code refers to + as type hints and let the controller fill them in at initialisation. See + [](../tutorials/dynamic-drivers.md). + +There is no free-function `attr()` factory: outside a class body, write the +constructor. diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py index b9e08edcd..80b848411 100644 --- a/src/fastcs/attributes/__init__.py +++ b/src/fastcs/attributes/__init__.py @@ -1,3 +1,8 @@ +from .attr_decorator import UnboundAttr as UnboundAttr +from .attr_decorator import UnboundAttrRW as UnboundAttrRW +from .attr_decorator import UnboundGetter as UnboundGetter +from .attr_decorator import UnboundSetter as UnboundSetter +from .attr_decorator import attr as attr from .attr_r import AttrR as AttrR from .attr_r import Getter as Getter from .attr_r import NotPolled as NotPolled diff --git a/src/fastcs/attributes/attr_decorator.py b/src/fastcs/attributes/attr_decorator.py new file mode 100644 index 000000000..905e77501 --- /dev/null +++ b/src/fastcs/attributes/attr_decorator.py @@ -0,0 +1,356 @@ +"""``@attr`` decorator sugar over the getter/setter constructors (ADR 0018). + +``@attr`` is the one-decorated-getter spelling a PyTango user expects, written +over the same machinery as the procedural ``AttrR(getter=...)`` / +``AttrRW(getter=..., setter=...)`` form rather than beside it. It is a +decorator only - there is no free-function ``attr()`` factory, and no +``@attr_r``/``@attr_rw``: an ``AttrR`` is a decorated getter, an ``AttrRW`` is +that plus a ``@x.setter``, and a write-only ``AttrW`` is rare enough to write +longhand. + +Binding follows ``@command``/``@scan``: the class body holds an `UnboundAttr` +describing the attribute, and each controller instance gets a fresh +``AttrR``/``AttrRW`` built from it at construction time. Nothing is deepcopied +from a class-scope prototype, so two instances of a controller never share an +attribute. +""" + +from __future__ import annotations + +from asyncio import iscoroutinefunction +from collections.abc import Awaitable, Callable +from inspect import Parameter, Signature, getdoc, signature +from types import MethodType +from typing import Any, Generic, Unpack, cast, overload + +from fastcs.attributes._infer_datatype import ( + _datatype_for_annotation, + _unwrap_update_annotation, +) +from fastcs.attributes.attr_r import AttrR, NotPolled, Polled, Schedule +from fastcs.attributes.attr_rw import AttrRW +from fastcs.attributes.update import Update +from fastcs.datatypes import DType_T, Meta +from fastcs.util import Controller_T + +UnboundGetter = Callable[[Controller_T], Awaitable[DType_T | Update[DType_T]]] +"""An ``@attr`` getter, taking the `Controller` it will be bound to as ``self``""" +UnboundSetter = Callable[ + [Controller_T, DType_T], Awaitable[None | DType_T | Update[DType_T]] +] +"""An ``@x.setter`` setter, taking the `Controller` it will be bound to as ``self``""" + + +def _type_name(datatype: Any) -> str: + """A datatype as it was most likely written, to name it in an error.""" + return getattr(datatype, "__name__", None) or repr(datatype) + + +def _summary(docstring: str | None) -> str | None: + """The first paragraph of a docstring, as a single line. + + A description is the one-line label a transport shows next to the value, so + a longer docstring carries only its summary into one. + """ + if not docstring: + return None + + return " ".join(docstring.split("\n\n", 1)[0].split()) or None + + +def _method_signature(fn: Callable) -> Signature: + """Resolve the signature of an async ``@attr`` getter or setter. + + Args: + fn: The decorated function + + Returns: + The signature, with its annotations resolved + + Raises: + TypeError: If the function is not an async method + + """ + if not iscoroutinefunction(fn): + raise TypeError("must be an async function") + + return signature(fn, eval_str=True) + + +class UnboundAttr(Generic[Controller_T, DType_T]): + """An ``@attr``-decorated getter, and the metadata that goes with it. + + An instance of this class lives in the `Controller` class body, in place of + the method it decorates. It is a declaration rather than an attribute: each + `Controller` instance binds it into an ``AttrR`` of its own during + construction, so the getter is bound to that instance and nothing is shared + between instances. + + It is a (non-data) descriptor only so that the attribute reads as the + ``AttrR`` it becomes - ``self.voltage.readback`` rather than the + declaration. Once the controller has bound it the attribute is in the + instance dictionary, which a non-data descriptor does not intercept, so + ``__get__`` runs only before binding. + """ + + def __init__( + self, + getter: UnboundGetter[Controller_T, DType_T], + schedule: Schedule[DType_T] | None = None, + meta: Meta | None = None, + setter: UnboundSetter[Controller_T, DType_T] | None = None, + ) -> None: + try: + getter_signature = _method_signature(getter) + getter_parameters = list(getter_signature.parameters.values()) + if len(getter_parameters) != 1 or any( + parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for parameter in getter_parameters + ): + raise TypeError("must be a method taking self") + except TypeError as error: + raise TypeError(f"@attr getter {getter.__qualname__} {error}") from error + + annotation = _unwrap_update_annotation(getter_signature.return_annotation) + datatype = _datatype_for_annotation(annotation) + if datatype is None: + if annotation is not Signature.empty: + raise TypeError( + f"@attr getter {getter.__qualname__} must annotate a supported " + f"datatype, got {_type_name(annotation)}" + ) + raise TypeError( + f"@attr getter {getter.__qualname__} must annotate the datatype " + "the attribute holds as its return type, for example `-> float`" + ) + + if isinstance(schedule, Polled | NotPolled) and schedule.getter is not None: + raise TypeError( + f"The schedule given to @attr on {getter.__qualname__} already " + "has a getter; pass a bare Polled(period=...) or NotPolled()" + ) + + self._getter = getter + self._setter = setter + self._schedule = schedule + self._datatype = datatype + self._meta: dict[str, Any] = dict(meta or {}) + self._name = getter.__name__ + + def __set_name__(self, owner: type, name: str) -> None: + self._name = name + + @overload + def __get__( + self, instance: None, owner: type | None = None, / + ) -> UnboundAttr[Controller_T, DType_T]: ... + + @overload + def __get__( + self, instance: object, owner: type | None = None, / + ) -> AttrR[DType_T]: ... + + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + if instance is None: + return self + + raise AttributeError( + f"Attribute '{self._name}' does not exist yet. An @attr declaration " + "becomes an attribute when the controller is constructed, so it " + "cannot be reached before Controller.__init__ has run." + ) + + @property + def datatype(self) -> Any: + """The datatype inferred from the getter's return annotation.""" + return self._datatype + + def has_setter(self) -> bool: + return self._setter is not None + + def setter( + self, fn: UnboundSetter[Controller_T, DType_T] + ) -> UnboundAttrRW[Controller_T, DType_T]: + """Declare the writer half, making this an ``AttrRW``. + + Mirrors ``@property``/``@x.setter``, so a read-write attribute is one + name with two decorated methods:: + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + Args: + fn: The setter, taking ``self`` and the value to apply + + Returns: + A new `UnboundAttrRW` with the setter attached. This one is left + alone, so a subclass declaring a setter does not also give one to + the base class it inherited the getter from. + + Raises: + TypeError: If the setter is not an async method taking a value, or + annotates a value of a different datatype to the getter's + + """ + if self._setter is not None: + raise TypeError( + f"@attr getter {self._getter.__qualname__} already has a setter" + ) + + try: + setter_signature = _method_signature(fn) + setter_parameters = list(setter_signature.parameters.values()) + if len(setter_parameters) != 2 or any( + parameter.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD) + for parameter in setter_parameters + ): + raise TypeError("must be a method taking self and the value to set") + except TypeError as error: + raise TypeError(f"@attr setter {fn.__qualname__} {error}") from error + + value = list(setter_signature.parameters.values())[1] + if value.annotation is not Signature.empty: + if _datatype_for_annotation(value.annotation) is not self._datatype: + raise TypeError( + f"@attr setter {fn.__qualname__} takes a " + f"{_type_name(value.annotation)}, but its getter returns a " + f"{_type_name(self._datatype)}" + ) + + return UnboundAttrRW( + self._getter, + schedule=self._schedule, + meta=cast(Meta, self._meta), + setter=fn, + ) + + def bind(self, controller: Controller_T) -> AttrR[DType_T]: + """Build the attribute this declares, for one `Controller` instance. + + Args: + controller: The controller whose methods the getter and setter are + + Returns: + An ``AttrR``, or an ``AttrRW`` if a setter was declared + + """ + getter = MethodType(self._getter, controller) + scheduled = getter if self._schedule is None else self._schedule(getter) + + meta = dict(self._meta) + if "description" not in meta: + description = _summary(getdoc(self._getter)) + if description is not None: + meta["description"] = description + + if self._setter is None: + attribute = AttrR(self._datatype, getter=scheduled, **meta) + else: + attribute = AttrRW( + self._datatype, + getter=scheduled, + setter=MethodType(self._setter, controller), + **meta, + ) + + return cast(AttrR[DType_T], attribute) + + def __repr__(self) -> str: + access_mode = "rw" if self._setter is not None else "r" + return ( + f"{type(self).__name__}({self._getter.__qualname__}, " + f"access_mode={access_mode!r}, datatype={_type_name(self._datatype)})" + ) + + +class UnboundAttrRW(UnboundAttr[Controller_T, DType_T]): + """An `UnboundAttr` that has been given a setter, so it binds an ``AttrRW``. + + A separate class only so that a declaration carrying a setter reads as the + ``AttrRW`` it becomes, and one without it as an ``AttrR``. + """ + + @overload + def __get__( + self, instance: None, owner: type | None = None, / + ) -> UnboundAttrRW[Controller_T, DType_T]: ... + + @overload + def __get__( + self, instance: object, owner: type | None = None, / + ) -> AttrRW[DType_T]: ... + + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + return super().__get__(instance, owner) + + def bind(self, controller: Controller_T) -> AttrRW[DType_T]: + return cast(AttrRW[DType_T], super().bind(controller)) + + +@overload +def attr( + getter: UnboundGetter[Controller_T, DType_T], / +) -> UnboundAttr[Controller_T, DType_T]: ... + + +@overload +def attr( + schedule: Schedule[Any] | None = None, /, **meta: Unpack[Meta] +) -> Callable[ + [UnboundGetter[Controller_T, DType_T]], UnboundAttr[Controller_T, DType_T] +]: ... + + +def attr(getter_or_schedule: Any = None, /, **meta: Any) -> Any: + """Declare an `Attribute` from the method that reads it. + + The datatype is the getter's return annotation and the getter's docstring + is the attribute's description, so the common "one attribute, one device + call" case is a single decorated method:: + + class PowerSupply(Controller): + @attr(Polled(period=0.5), units="V") + async def voltage(self) -> float: + \"\"\"Output voltage.\"\"\" + return float(await self._conn.query("V?")) + + @voltage.setter + async def voltage(self, value: float) -> None: + await self._conn.send(f"V={value}") + + The optional leading positional argument is a schedule - the same + `Polled`/`NotPolled` objects the procedural form wraps its getter in, so + the two spellings share one vocabulary: + + - ``@attr(units="V")`` is read once, when the controller connects, which is + what a bare ``getter=`` means and what a bare ``@attr`` means + - ``@attr(Polled(period=0.5))`` is read every 0.5 seconds, as + ``AttrR(getter=Polled(g, period=0.5))`` is + - ``@attr(NotPolled())`` is never read on a schedule, as + ``AttrR(getter=NotPolled(g))`` is + + Args: + getter_or_schedule: The getter, when used bare as ``@attr``; otherwise + a `Polled` or `NotPolled` schedule, or nothing + meta: Metadata for the attribute, checked against the datatype the + getter returns - ``precision`` on a ``str`` attribute raises + + Returns: + An `UnboundAttr`, which each `Controller` instance binds into an + attribute of its own + + """ + if getter_or_schedule is not None and not isinstance( + getter_or_schedule, Polled | NotPolled + ): + # Bare ``@attr``, so what we have is the getter itself. There is no way + # to pass metadata in that form, so there is none to carry over. + return UnboundAttr(getter_or_schedule) + + def wrapper( + getter: UnboundGetter[Controller_T, DType_T], + ) -> UnboundAttr[Controller_T, DType_T]: + return UnboundAttr(getter, schedule=getter_or_schedule, meta=cast(Meta, meta)) + + return wrapper diff --git a/src/fastcs/controllers/base_controller.py b/src/fastcs/controllers/base_controller.py index 05f207c2e..ef3b76252 100755 --- a/src/fastcs/controllers/base_controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -1,6 +1,7 @@ from __future__ import annotations from copy import deepcopy +from inspect import getattr_static from typing import ( TypeVar, _GenericAlias, # type: ignore @@ -9,7 +10,7 @@ get_type_hints, ) -from fastcs.attributes import Attribute, HintedAttribute +from fastcs.attributes import Attribute, HintedAttribute, UnboundAttr from fastcs.controllers.controller_api import ControllerAPI from fastcs.logging import logger from fastcs.methods import Command, Method, Scan, UnboundCommand, UnboundScan @@ -117,6 +118,9 @@ def _bind_attrs(self) -> None: controller class. For Methods, this requires creating a bound method from a class method and a controller instance, so that it can be called from any context with the controller instance passed as the ``self`` argument. + An ``@attr``-decorated getter is an `UnboundAttr` declaration rather than + an Attribute, and is bound the same way the Methods are - into a fresh + Attribute whose getter and setter are methods of this instance. """ class_dir = dict.fromkeys(self._walk_mro()) @@ -130,6 +134,13 @@ class method and a controller instance, so that it can be called from any if attr_name == "root_attribute": continue + # An ``UnboundAttr`` is a descriptor that refuses to be read before + # it is bound, so reach past it to the declaration itself. + declaration = getattr_static(self, attr_name, None) + if isinstance(declaration, UnboundAttr): + self.add_attribute(attr_name, declaration.bind(self)) + continue + attr = getattr(self, attr_name, None) if isinstance(attr, Attribute): setattr(self, attr_name, deepcopy(attr)) diff --git a/tests/test_attr_decorator.py b/tests/test_attr_decorator.py new file mode 100644 index 000000000..711cca1c9 --- /dev/null +++ b/tests/test_attr_decorator.py @@ -0,0 +1,399 @@ +import asyncio +from enum import Enum + +import numpy as np +import pytest + +from fastcs.attributes import ( + AttrR, + AttrRW, + NotPolled, + Polled, + UnboundAttr, + Update, + attr, +) +from fastcs.controllers import Controller +from fastcs.datatypes import Array1D, Limits, NumericLimits +from fastcs.util import ONCE + + +class State(Enum): + IDLE = "idle" + BUSY = "busy" + + +class PowerSupply(Controller): + """A controller declaring its attributes with ``@attr``.""" + + def __init__(self) -> None: + super().__init__() + + self.sent: list[float] = [] + self._voltage = 1.5 + + @attr(Polled(period=0.5), units="V", precision=3) + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + """Output voltage. + + The rest of the docstring says more than a description should. + """ + return self._voltage + + @voltage.setter + async def voltage(self, value: float) -> None: + self.sent.append(value) + self._voltage = value + + @attr + async def serial(self) -> str: + """Serial number.""" + return "PSU-1" + + @attr(NotPolled(), group="Config") + async def retries(self) -> int: + return 3 + + +def test_getter_only_is_read_only(): + controller = PowerSupply() + + assert isinstance(controller.serial, AttrR) + assert not isinstance(controller.serial, AttrRW) + assert controller.serial.dtype is str + assert controller.serial.access_mode == "r" + + +def test_getter_and_setter_is_read_write(): + controller = PowerSupply() + + assert isinstance(controller.voltage, AttrRW) + assert controller.voltage.dtype is float + assert controller.voltage.access_mode == "rw" + + +def test_attributes_are_registered_with_the_controller(): + controller = PowerSupply() + + assert list(controller.attributes) == ["voltage", "serial", "retries"] + assert controller.attributes["voltage"] is controller.voltage + assert controller.voltage.name == "voltage" + + +def test_metadata_from_decorator(): + controller = PowerSupply() + + assert controller.voltage.meta == { + "units": "V", + "precision": 3, + "description": "Output voltage.", + } + assert controller.retries.meta == {"group": "Config"} + assert controller.retries.group == "Config" + + +def test_docstring_summary_becomes_the_description(): + controller = PowerSupply() + + # Only the first paragraph - a description is a one-line label. + assert controller.voltage.description == "Output voltage." + assert controller.serial.description == "Serial number." + assert controller.retries.description is None + + +def test_explicit_description_wins_over_the_docstring(): + class Device(Controller): + @attr(description="From the decorator") + async def label(self) -> str: + """From the docstring.""" + return "x" + + assert Device().label.description == "From the decorator" + + +def test_schedules(): + controller = PowerSupply() + + assert controller.voltage.poll_period == 0.5 + # A bare ``@attr`` means what a bare ``getter=`` means - read once, at connect. + assert controller.serial.poll_period is ONCE + assert controller.retries.poll_period is None + assert controller.retries.has_getter() + + +@pytest.mark.asyncio +async def test_bound_getter_reads_from_its_own_instance(): + one, two = PowerSupply(), PowerSupply() + two._voltage = 9.0 + + assert await one.voltage.poll() == 1.5 + assert await two.voltage.poll() == 9.0 + + +@pytest.mark.asyncio +async def test_bound_setter_writes_to_its_own_instance(): + one, two = PowerSupply(), PowerSupply() + + await one.voltage.set(2.5) + + assert one.sent == [2.5] + assert one._voltage == 2.5 + assert two.sent == [] + assert two._voltage == 1.5 + + +def test_each_instance_gets_a_fresh_attribute(): + one, two = PowerSupply(), PowerSupply() + + assert one.voltage is not two.voltage + assert one.serial is not two.serial + + +def test_class_body_holds_the_declaration(): + assert isinstance(PowerSupply.voltage, UnboundAttr) + assert PowerSupply.voltage.datatype is float + assert PowerSupply.voltage.has_setter() + assert not PowerSupply.serial.has_setter() + assert "PowerSupply.voltage" in repr(PowerSupply.voltage) + assert "access_mode='rw'" in repr(PowerSupply.voltage) + + +def test_datatype_inferred_from_the_return_annotation(): + class Device(Controller): + @attr + async def flag(self) -> bool: + return True + + @attr + async def state(self) -> State: + return State.IDLE + + @attr(shape=(4,)) + async def trace(self) -> Array1D[np.int32]: + return np.zeros(4, dtype=np.int32) + + controller = Device() + + assert controller.flag.dtype is bool + assert controller.state.dtype is State + assert controller.trace.dtype is np.ndarray + assert controller.trace.meta == {"array_dtype": np.int32, "shape": (4,)} + + +@pytest.mark.asyncio +async def test_update_return_annotation_is_unwrapped(): + class Device(Controller): + @attr + async def temperature(self) -> Update[float]: + return Update(readback=20.5, timestamp=1000.0) + + controller = Device() + + assert controller.temperature.dtype is float + assert await controller.temperature.poll() == 20.5 + assert controller.temperature.timestamp == 1000.0 + + +def test_metadata_is_validated_against_the_inferred_datatype(): + class Device(Controller): + @attr(precision=3) + async def label(self) -> str: + return "x" + + with pytest.raises(TypeError, match="'precision' is not valid metadata"): + Device() + + +def test_limits_metadata(): + class Device(Controller): + @attr(limits=NumericLimits(control=Limits(0.0, 10.0))) + async def setpoint(self) -> float: + return 1.0 + + assert Device().setpoint.meta.get("limits") == NumericLimits( + control=Limits(0.0, 10.0) + ) + + +def test_matching_type_hint_is_satisfied_by_the_decorated_attribute(): + class Device(Controller): + label: AttrR[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + controller = Device() + + assert isinstance(controller.label, AttrR) + + +def test_type_hint_of_the_wrong_access_mode_raises(): + class Device(Controller): + label: AttrRW[str] # pyright: ignore[reportRedeclaration] + + @attr + async def label(self) -> str: + return "x" + + with pytest.raises(RuntimeError, match="does not match defined access mode"): + Device() + + +def test_name_clash_with_an_attribute_added_later_raises(): + class Device(Controller): + def __init__(self) -> None: + super().__init__() + + self.label = AttrR(str) # pyright: ignore[reportAttributeAccessIssue] + + @attr + async def label(self) -> str: + return "x" + + with pytest.raises(ValueError, match="Cannot add attribute") as exc_info: + Device() + + assert "has existing attribute label" in str(exc_info.value.__cause__) + + +def test_getter_must_be_async(): + with pytest.raises(TypeError, match="getter .* must be an async function"): + + @attr() # pyright: ignore[reportArgumentType] + def voltage(self) -> float: + return 0.0 + + +def test_getter_must_take_only_self(): + with pytest.raises(TypeError, match="getter .* must be a method taking self"): + + @attr() # pyright: ignore[reportArgumentType] + async def voltage(self, index: int) -> float: + return 0.0 + + +def test_getter_must_annotate_its_return_type(): + with pytest.raises(TypeError, match="must annotate the datatype"): + + @attr() + async def voltage(self): + return 0.0 + + +def test_getter_must_return_a_supported_datatype(): + with pytest.raises(TypeError, match="must annotate a supported datatype"): + + @attr() # pyright: ignore[reportArgumentType] + async def voltage(self) -> list[int]: + return [] + + +def test_setter_must_be_async(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises(TypeError, match="setter .* must be an async function"): + + @voltage.setter # pyright: ignore[reportArgumentType] + def voltage(self, value: float) -> None: + pass + + +def test_setter_must_take_a_value(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises( + TypeError, match="setter .* must be a method taking self and the value to set" + ): + + @voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self) -> None: + pass + + +def test_setter_value_must_match_the_getter_datatype(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + with pytest.raises(TypeError, match="takes a str, but its getter returns a float"): + + @voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self, value: str) -> None: + pass + + +def test_setter_value_annotation_is_optional(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + @voltage.setter + async def voltage(self, value) -> None: + pass + + assert voltage.has_setter() + + +def test_only_one_setter(): + @attr + async def voltage(self) -> float: # pyright: ignore[reportRedeclaration] + return 0.0 + + @voltage.setter + async def voltage(self, value: float) -> None: + pass + + with pytest.raises(TypeError, match="already has a setter"): + + @voltage.setter + async def voltage(self, value: float) -> None: + pass + + +def test_setter_does_not_leak_onto_the_class_it_was_inherited_from(): + class Base(Controller): + @attr + async def voltage(self) -> float: + return 0.0 + + class Child(Base): + @Base.voltage.setter # pyright: ignore[reportArgumentType] + async def voltage(self, value: float) -> None: + pass + + assert not Base.voltage.has_setter() + assert Child.voltage.has_setter() + assert not isinstance(Base().voltage, AttrRW) + assert isinstance(Child().voltage, AttrRW) + + +def test_schedule_must_not_already_have_a_getter(): + async def read() -> float: + return 0.0 + + with pytest.raises(TypeError, match="already has a getter"): + + @attr(Polled(read, period=0.1)) + async def voltage(self) -> float: + return 0.0 + + +@pytest.mark.asyncio +async def test_polled_attributes_are_scheduled(): + controller = PowerSupply() + _, periodic, initial = controller.create_api_and_tasks() + + # ``serial`` is read once at connect; ``voltage`` is polled at 0.5s; + # ``retries`` is never read on a schedule. + assert len(initial) == 1 + assert len(periodic) == 1 + + await asyncio.gather(*[coro() for coro in initial]) + + assert controller.serial.readback == "PSU-1" + assert controller.retries.readback == 0