Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b289e40
docs: ADR 13 - declarative/procedural split and ControllerFiller
claude Jul 20, 2026
710433c
docs: ADR 14 - AttributeIO R/W/RW rework, remove AttributeIORef
claude Jul 20, 2026
3ce16b9
docs: ADR 15 - typed commands
claude Jul 20, 2026
6d6a191
docs: ADR 16 - setpoint cache, native timestamps, ControllerRunner
claude Jul 20, 2026
b8707b0
docs: ADR 17 - naming pass (precision, Limits, Array1D/Table hints)
claude Jul 20, 2026
cd10e12
docs: ADR 18 - @attr_r/@attr_rw decorator sugar
claude Jul 20, 2026
5e87051
docs: ADR 19 - embedded ophyd-async connector
claude Jul 20, 2026
56b03fa
examples: scaffold living-review-artifact package for #388
claude Jul 20, 2026
ce9ccc0
docs: fold #402 review resolutions into ADRs 0013-0019
coretl Jul 21, 2026
8979bd0
docs: spell out the *Meta metadata model in ADRs (DataType replacement)
coretl Jul 21, 2026
85df0f5
docs: ADR 15 - drop Command[Any, Any], no partial typing
coretl Jul 21, 2026
8a4e4a8
docs: consolidate examples into fastcs.demo; SCPIParam one-spec design
coretl Jul 22, 2026
2818195
docs: getter/setter IO model; @attr decorator-only; 4-tutorial ladder
coretl Jul 22, 2026
3da025f
docs: ADR 14 - runtime surface (.readback/.setpoint, poll()/poll_peri…
coretl Jul 22, 2026
96cb4bc
demo: use ControllerVector for temperature ramp sub-controllers
claude Jul 22, 2026
cf15507
demo: cut-down Eiger REST sim + introspectable controller example
claude Jul 22, 2026
c4cb0fc
docs: ignore unresolvable httpx/fastapi type refs in nitpicky mode
claude Jul 22, 2026
54522d7
docs: fold @shihab-dls #402 replies into ADRs 0014 & 0019
coretl Jul 23, 2026
1932e5e
demo: drop redundant type hint on ramps assignment
claude Jul 23, 2026
ff6292b
demo(#391): address review - idle derived attr, temp oscillation, pol…
coretl Jul 23, 2026
ef0c3c6
docs: rewrite ADRs 0013-0019 with resolved-question tangle removed
coretl Jul 23, 2026
da9988c
demo(#391): address review - simpler sim flip, backdoor, front-door t…
coretl Jul 23, 2026
8f3be18
test(demo): assert cancel_all puts Off on each ramp's enabled attr
coretl Jul 30, 2026
34196c6
demo(#391): introspect state as an enum from allowed_values; drop IO …
coretl Jul 30, 2026
02cbbec
Merge pull request #409 from DiamondLightSource/refactor-issue-390
coretl Aug 3, 2026
68f978a
Merge pull request #410 from DiamondLightSource/refactor-issue-391
coretl Aug 3, 2026
83bcf67
demo: getter/setter-in-init temperature attr example
claude Jul 22, 2026
b68e74a
fix: ruff-format line-length nit
claude Jul 22, 2026
a8db570
demo: reset _connected on close in temperature_attr controller
claude Jul 23, 2026
214aaf2
demo(#404): single callable-wrapping IO + protocol class (Thorlabs sh…
coretl Jul 23, 2026
42bd98c
test: drop unnecessary # type: ignore[method-assign] in temperature_a…
coretl Jul 30, 2026
ff9b4b7
demo(#404): convert existing temperature controller to getter/setter
coretl Aug 3, 2026
e73453b
demo(#404): rename controllers.py to temperature_attr.py
coretl Aug 3, 2026
326d31f
Merge pull request #411 from DiamondLightSource/refactor-issue-404
coretl Aug 3, 2026
f555808
attributes: getter/setter IO rework, remove AttributeIORef/AttributeI…
coretl Aug 14, 2026
4d89906
methods: typed commands — positional arguments and a return value (#419)
coretl Sep 3, 2026
997aea4
attributes: replace the DataType family with python types and `*Meta`…
coretl Sep 3, 2026
127c5ea
controllers: ControllerRunner, plus native timestamps and severity on…
coretl Sep 3, 2026
fc74689
attributes: `@attr` decorator sugar over the getter/setter constructo…
coretl Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/*bob

# GUI files emitted by the example IOCs in tests/
/opis/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
117 changes: 117 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -98,9 +101,12 @@
("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"),
# 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"),
Expand Down
16 changes: 8 additions & 8 deletions docs/explanations/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,21 @@ 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`.

```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)
Expand Down Expand Up @@ -72,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):
...
Expand Down Expand Up @@ -107,7 +107,7 @@ from fastcs.controllers import Controller, ControllerVector


class ChannelController(Controller):
value = AttrR(Float())
value = AttrR(float)


class RootController(Controller):
Expand Down Expand Up @@ -154,7 +154,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`
Expand Down
Loading