Skip to content

Check a beamline descriptor's declared addresses against the live control system - #733

Open
xmap wants to merge 1 commit into
mainfrom
feat/descriptor-probe
Open

Check a beamline descriptor's declared addresses against the live control system#733
xmap wants to merge 1 commit into
mainfrom
feat/descriptor-probe

Conversation

@xmap

@xmap xmap commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Adds python -m cora.api.descriptor_preflight <descriptor>: an operator command that reads every control address a beamline descriptor declares, once, and reports whether it connects and what shape ControlPort sees it as.

Why

A descriptor's addresses were reverse-engineered from a controls config or a design document, and nothing has ever compared them to the running control system. The two sides are independently maintained, so a disagreement is real evidence rather than a restatement (the property project_independent_check_principle asks for). The drift it catches is the class that has already shipped three times on the capture-watch PV set: an address that moved, got renamed by an IOC rebuild, or reads as a wire shape its author did not expect.

Naming

preflight, not probe: a probe in CORA is a periodic re-affirmation read that writes a coverage-trail row (CaptureProbe, the three *_probe_tick_seconds settings). This is a one-shot operator sweep that records nothing, which is the act capture_watch_preflight already names.

address, not handle: address is what ControlPort.read takes and what NoAdapterForAddressError reports. handle already means an opaque id (TransferHandle) and is the descriptor's own word for the unrelated epics_handle provenance field.

_EXIT_MISMATCH = 1 follows record_fidelity_check's "1 means what you checked disagrees" contract rather than reusing capture_watch_preflight's _EXIT_PROBLEM, which is the same symbol name for a different value (2).

Safety posture

Read-only, and more strictly than the deployment: build_control_port takes writes_enabled=False as a literal rather than inheriting Settings.control_writes_enabled, because this command has no write path at any setting. An integration test asserts read-preflight-read leaves the value unchanged.

The module docstring carries the wire-footprint note: CA search is broadcast and an unresolvable address is the noisy case, so pointing this at a beamline outside the deployment's own means pinning EPICS_CA_AUTO_ADDR_LIST=NO with a named gateway, and asking that beamline's staff first.

The corpus guard found two defects before the command ran once

The field walk was written from 2-BM, then ranged over all 96 deployments/*/beamline.yaml. It failed immediately:

  • Five address-bearing fields were never reached: readback_pv, slot_labels_pv, camera_rotation_pv, per_lens_focus_pv, temperature_pv. 2-BM went from 56 readable addresses to 65.
  • An address field whose value is a bare list was dropped silently.

Both are now pinned by test_address_fields_cover_the_descriptor_corpus, which fails on any address-shaped descriptor key classified as neither readable (ADDRESS_FIELDS) nor deliberately skipped (NOT_ADDRESS_FIELDS), so the next one is a decision rather than a silent gap. The guard was mutation-tested (drop a field, confirm it fails with an actionable message) so it is not vacuous.

epics_handle is the interesting exclusion: it carries crate provenance, so its values are ranges (2bmb:m100-m102), globs (2bmb:m*) and hostnames (JenaNV200D). Several would have classified as addresses and failed at read, reporting drift that is not there.

Reporting

Three shapes are reported as skipped rather than dropped (a bare device prefix, a confirm: marker, a non-address token like 2-BM's GV1 valve labels), and the pass rate counts only what was read. A descriptor full of confirm: markers must not be able to report a clean sweep having contacted nothing.

What a green row does not mean

A connected channel proves a record answers to that name, not that it drives the device the descriptor names. A renumbered motor crate answers on every one of its old names and this command reports a clean sweep. Stated in the module docstring so no reader takes a green report for confirmation of device identity.

Tests

42, all green.

  • Unit: extraction over inline fragments plus the real corpus; substrate outcomes mapped through a scripted fake ControlPort (not-connected / timeout / access-denied / coercion / no-route), dedup, and report rendering.
  • Integration: against a real epicscorelibs.ioc subprocess on loopback. This tier matters more than usual here, because all-BAD is the command's normal output on a host with no route to the beamline and is also what a completely broken command prints. The soft IOC proves green rows are reachable through the real EpicsCaControlPort, that a dead address reports BAD, that an unrouted address is named as a routes gap, and that the command writes nothing.

Dependency change

PyYAML moves from the dev group to a runtime dependency. It was dev-only while the descriptor was read exclusively by scripts/ and the docs build; this command runs on a deployment host where that group may be absent. types-PyYAML stays in dev (stubs only). The lockfile diff is 4 lines; uv lock --check passes.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  descriptor_preflight.py 187, 449-471
Project Total  

This report was generated by python-coverage-comment-action

…trol system

A descriptor names control addresses that were reverse-engineered from a
controls config or a design document, and nothing has ever compared them to
the running control system. The two sides are independently maintained, so a
disagreement is real evidence rather than a restatement: this is the property
project_independent_check_principle asks for, and re-reading the YAML cannot
supply it. The drift it catches is the class that has already shipped three
times on the capture-watch PV set: an address that moved, got renamed by an
IOC rebuild, or reads as a wire shape its author did not expect.

Named preflight, not probe, and address, not handle, to stay inside the house
vocabulary. A probe in CORA is a periodic re-affirmation read that writes a
coverage-trail row (CaptureProbe, the three *_probe_tick_seconds settings);
this is a one-shot operator sweep that records nothing, and it is the same act
capture_watch_preflight performs. An address is what ControlPort.read takes
and what NoAdapterForAddressError reports, while handle already means an
opaque id (TransferHandle) and is the descriptor's own word for the unrelated
epics_handle provenance field, which NOT_ADDRESS_FIELDS excludes.

Read-only, and more strictly than the deployment: build_control_port takes
writes_enabled=False as a literal rather than inheriting
Settings.control_writes_enabled, because this command has no write path at any
setting. _EXIT_MISMATCH follows record_fidelity_check's 1-means-disagreement
contract rather than reusing capture_watch_preflight's _EXIT_PROBLEM, which is
the same symbol name for a different value.

Ranging the field walk over the whole deployments/ corpus rather than over
2-BM alone found two defects in it before it ran once. Five address-bearing
fields (readback_pv, slot_labels_pv, camera_rotation_pv, per_lens_focus_pv,
temperature_pv) were never reached, and an address field whose value is a bare
list was dropped silently. Both are pinned by a guard that fails on any
address-shaped descriptor key classified as neither readable nor deliberately
skipped, so the next one is a decision rather than a silent gap. epics_handle
is the interesting exclusion: it carries crate provenance, so its values are
ranges, globs and hostnames that would read as dead addresses.

Three shapes are reported as skipped rather than dropped (a bare device
prefix, a confirm: marker, a non-address token like 2-BM's GV1 valve labels),
and the pass rate counts only what was read. A descriptor full of confirm:
markers must not be able to report a clean sweep having contacted nothing.

PyYAML moves from the dev group to a runtime dependency. It was dev-only
while the descriptor was read exclusively by scripts/ and the docs build, and
this command is run on a deployment host where that group may be absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xmap
xmap force-pushed the feat/descriptor-probe branch from e421bb5 to c953665 Compare August 26, 2026 14:20
@xmap xmap changed the title Check a beamline descriptor's declared handles against the live control system Check a beamline descriptor's declared addresses against the live control system Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant