demo: SCPI example — annotated attributes with per-attribute filler data - #427
demo: SCPI example — annotated attributes with per-attribute filler data#427coretl wants to merge 2 commits into
Conversation
Example 4 of the tutorial sequence, and the declarative rung: a device that
does NOT describe itself, whose attributes are hand-annotated in the class
body and provisioned from that static metadata by a `ControllerFiller`.
`SCPIParam` is one spec object per attribute - the command token plus all
generic metadata in the same place, because they describe the same thing:
ramp_rate: Annotated[
AttrRW[float], SCPIParam("R", precision=3, units="K/s")
]
Both new modules live in the demo (protocol) layer, not core FastCS: ADR 0014
decision 3 says core ships no extras vocabulary for 1.0. Core defines the
mechanism - the extras an `Annotated` hint carries through to the filler -
and this is the worked example of a third party building on it. `SCPIParam`
is a sibling of ophyd-async's `PvSuffix`, not a FastCS type; hence the name,
rather than `SCPIMeta`, since the `*Meta` suffix is reserved for the
`Unpack`-able typed dicts.
- `demo/scpi.py`: `SCPIParam` and `SCPIController`. The controller reads its
own declarations, builds each attribute's getter and setter from the token,
and passes the param's `.meta` to `fill_attribute`, which runtime-validates
it against the datatype the hint declared. The access mode comes from the
hint: an `AttrR[...]` gets no setter. A `suffix` addresses one channel of a
device, so a per-ramp sub controller is the same class with `01` appended
to every token.
- `demo/temperature_scpi.py`: the full multi-ramp temperature controller
declared this way, running on the existing temperature sim - its text
protocol is already SCPI-shaped, so no new sim is invented and, crucially,
no introspection is. It carries the composition and methods rungs too: a
`ControllerVector` of ramp sub controllers, the voltages `@scan`, and the
cancel-all `@command`.
Two attributes deliberately carry no token: `voltages` and each ramp's
`voltage` are updated by the parent's scan in one query rather than one per
ramp, so the driver fills them itself. That is what shows `SCPIController`
filling what it owns and leaving the rest alone.
Tests cover the wire format, the per-ramp suffix, the enum datatype coming
from the hint, and the negative case the issue asks for: `precision` on a
`str` attribute raises, naming the attribute and the field.
Closes #405
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BymsiALDVCGv1nJWK8DyAV
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## refactor #427 +/- ##
============================================
+ Coverage 91.25% 92.63% +1.38%
============================================
Files 72 70 -2
Lines 2892 3449 +557
============================================
+ Hits 2639 3195 +556
- Misses 253 254 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Both failures were in what I could not see locally rather than in the code. `pre-commit run --all-files` only sees files git knows about, so the three new modules were invisible to it until they were staged - ruff-format then wanted `TemperatureRampController.__init__`'s super() call on one line. Staged first this time, and verified. The docs job builds with a network, so `default_role = "any"` resolves against the real intersphinx inventories; two single-backtick spans in the new docstrings - ``__init__`` and ``voltage`` - are prose rather than cross-references, and are now literals. Offline they were hidden among the intersphinx misses that having no network produces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BymsiALDVCGv1nJWK8DyAV
shihab-dls
left a comment
There was a problem hiding this comment.
This example has demonstrated the need for some kind of AttrBackend and AttrFactory. Follow this attached spec to implement these requirements, in order to update the SCPI example.
attribute_factory.md
Closes #405
Tutorial example 4, and the declarative rung: a device that does not describe itself, whose attributes are hand-annotated in the class body and provisioned from that static metadata by
ControllerFiller. A SCPI device is the honest case for this — it has no parameter tree to walk, which is why you annotate — and keeping it non-introspectable preserves the contrast with the Eiger example (#391).Runs on the existing temperature-controller backend — its text protocol is already SCPI-shaped (
R?to read,R=1.5to write), so no new sim is invented and, crucially, no introspection is.Scope
src/fastcs/demo/scpi.py—SCPIParamandSCPIController.SCPIParam(param, **kwargs: Unpack[Meta])is the single place an attribute's whole spec is written: the command token plus all generic metadata. It stores a.metaand is the exclusive spec source for its attribute — the controller does not also merge a separateFloatMetaextra on the same hint, so there is one place to look for what an attribute is.SCPIController.initialisereads its own declarations, builds each attribute's getter and setter from the token, and passes.metatofill_attribute, which runtime-validates it against the datatype the hint declared.AttrR[...]gets no setter, anAttrRW[...]gets both. Asuffixaddresses one channel, so a per-ramp sub controller is the same class with01appended to every token — no dispatching on which ramp at IO time.src/fastcs/demo/temperature_scpi.py— the full multi-ramp temperature controller declared this way. It carries the composition and methods rungs too (the old "io= pattern" tutorial being gone): aControllerVectorof ramp sub controllers, the voltages@scan, and the cancel-all@command.Where this lives, and why
Both modules are in the demo (protocol) layer, not core FastCS, per ADR 0014 decision 3: core ships no extras vocabulary for 1.0. Core defines the mechanism — the
extrasanAnnotatedhint carries through toControllerFiller— and this is the worked example of a third party building on it. NamedSCPIParamrather thanSCPIMeta: it is a binding extra you instantiate, a sibling of ophyd-async'sPvSuffix, and the*Metasuffix is reserved for theUnpack-able typed dicts.Instructions to reviewer on how to test:
uv run pytest tests/demo/test_temperature_scpi.py -vsrc/fastcs/demo/temperature_scpi.pyagainsttemperature_attr.pyside by side — same device, same attributes, procedural vs declarative.Checks for reviewer
voltagesand each ramp'svoltageare updated by the parent's@scan, which reads every ramp's voltage in one query rather than one per ramp, so the driver fills them itself (fill_attribute("voltage", precision=3, units="V")). That is also what demonstratesSCPIControllerfilling what it owns and leaving the rest to the driver — but say if you would rather every attribute went throughSCPIParamfor uniformity, even the ones with no command of their own.poll_periodis one class attribute, not per-SCPIParam. The issue specifiesSCPIParam(param, **kwargs: Unpack[Meta]), and a poll period is not metadata — it is not something a client is served — so it has no place inMeta. A controller whose attributes want different periods would need aperiodargument onSCPIParamoutside theMetaunpack; say if that is wanted and it is a small change.state: Annotated[AttrR, SCPIParam("ST")]has nothing to parse the device's text answer into, and a SCPI device cannot be asked — so the error says the hint must name its datatype. Unsubscripted hints without aSCPIParamkeep core's behaviour (a promise, reported bycheck_filled).float("1.5"),OnOffEnum("1")). Every datatype FastCS serves happens to support that, but nothing intype[DType]says so, so there is onecastwith a comment. The alternative is a per-datatype parse table in the demo, which seemed more machinery than the example is worth.tests/demo/test_temperature_attr.py. The negative case isprecisionon astrattribute, asserted to name both the field and the attribute.Notes
initialise/connect/reconnect), which is what is onrefactortoday. controllers: connections own health, reconnect and the retry budget #424 renames those tobuild/setupand moves connection ownership ontoConnection; whichever merges second will wantinitialise→buildand the three connection hooks dropped fromTemperatureControllerhere. Kept independent rather than stacked, per one issue = one branch = one PR.demo/fastcs.yaml: registering a second class there would turn the entry into a discriminated union and change the demo's schema, which is not this issue's scope.uv run --locked tox -e pre-commit,type-checking, both green in full. As on the rest of this series, this sandbox cannot rundocs(needs outbound network) or the PVA/p4p-backed tests (RuntimeError: Address family not supported by protocol). Excluding those,pytest src tests --ignore=tests/benchmarkingpasses 507, with only the same 10 pre-existing p4p/socket-family failures. I also built the docs offline with the version-switcher fetch stubbed — which caught four ambiguous module cross-references in the new docstrings that would have failed the real--fail-on-warningdocs job, now written as explicit:mod:roles.🤖 Generated with Claude Code
https://claude.ai/code/session_01BymsiALDVCGv1nJWK8DyAV
Generated by Claude Code