Skip to content

Read a two-state signal by its index, not the facility's word for it - #718

Merged
xmap merged 3 commits into
mainfrom
fix/two-state-ordinal
Aug 23, 2026
Merged

Read a two-state signal by its index, not the facility's word for it#718
xmap merged 3 commits into
mainfrom
fix/two-state-ordinal

Conversation

@xmap

@xmap xmap commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Summary

CORA reads interlock trips, hutch permits and beam shutters as two-state signals. Enum-valued ones arrive carrying both a numeric index and a facility-authored text label, and CORA kept only the label, matching it against a hardcoded set (ON/OFF, TRUE/FALSE, YES/NO). That set can never be complete, because nothing constrains what those words can be.

Three incidents have now had the same cause: the hutch permit (2026-08-09), the beam permits (2026-08-18), and BLEPS. Each was fixed by widening the dictionary.

  • Add Measurement.ordinal, the index carried beside the label rather than instead of it. Same division quality already makes with quality_detail. All four adapters populate it; every one already held the index at the moment it picked a label, then discarded it.
  • binary_code prefers the ordinal, keeps the conventional labels as the fallback for genuine string records. The keyword is required, so pyright enumerates the call sites rather than a reviewer.
  • Tango DevState is carved out: it has an ordinal, but it indexes Tango's global state vocabulary (ON = 0), so publishing it would resolve an ON device to false. Keyed on attr.type, not on a property of the value.
  • Closes a fail-open the hoist would otherwise have spread: int(0.4) truncates to 0, which means open on the polarity-inverted shutters. One consumer guarded this locally; the shared decoder now does.
  • Correct the 2-BM BLEPS PV names, which were derived from the PLC tags by a rule that does not hold. The config example every deployment copies listed PVs that do not exist.

Measured on arcturus 2026-08-23: the 2bmBLEPS IOC labels trips NO_FAULT/TRIP and its fault and comms flags ""/Present. The empty one is the healthy state of the flag saying whether BLEPS is talking at all. Resolves BLEPS-4, which questions.md carried as Blocks-go-live.

Why the index rather than a wider dictionary

The index generalizes where a dictionary cannot. Every substrate CORA supports hands one over; caproto already published it, so the fleet was inconsistent and only binary_code accepting both int and str hid it. MAX IV and ESRF are Tango floors on the same growth ladder and their labels are theirs to choose.

Known limitation, deliberately accepted

An unconventional label used to make a mis-pointed record fail closed by accident, and it no longer does. That protection was a coincidence: it fired only when the wrong record had unusual labels, missed every wrong record with conventional ones, and rejected correct records with unusual ones. Written into binary_signal's docstring, including why state-count hardening was considered and declined.

Test plan

  • Full suite clean: 50,460 passed, 1 pre-existing unrelated flake
  • Every guard mutation-verified. Two tests were decorative when first written and are recorded as such in their docstrings: the DevState test passed because its fake was not int-convertible rather than because the carve-out fired, and the out-of-range test used labels the fallback also rejects
  • Two soft-IOC records carrying BLEPS's real label pairs, so the proof runs over real DBR_ENUM rather than a shape no bi record produces (how the same defect shipped twice before)
  • Subscribe path covered, cold and warm label cache, since all three production observers consume subscribe rather than read
  • Cross-adapter drift test over all four ordinal predicates, modelled on the existing test_absent_timestamp_mapping.py
  • Two independent reviews; findings fixed include the float fail-open, a proxy-based Tango discriminator, PVA/caproto coverage holes, and several docstring overclaims

Follow-up found while verifying, NOT in this PR

A tripped BLEPS flag arrives with MAJOR severity (the IOC sets OSV=MAJOR so it annunciates), which CORA maps to Uncertain, and the BLEPS reader discards anything but Good. So a trip is still dropped even with this merged. Same bug the hutch permit already fixed by loosening its floor to Bad-only. Next slice, before BLEPS is enabled at 2-BM.

🤖 Generated with Claude Code

xmap and others added 2 commits August 23, 2026 13:57
Three times now a facility spelled its enum labels differently and CORA
read nothing: the hutch permit on 2026-08-09 (int('ON') raised), the
beam permits on 2026-08-18 (identical), and now BLEPS. Each was fixed by
widening the label set, which cannot converge. ZNAM/ONAM, Tango's
enum_labels and NTEnum's choices are free text one engineer chose at one
facility, constrained by nothing, so no label set is ever complete.

Measured on arcturus 2026-08-23 against the running 2bmBLEPS IOC
(iocBoot/ioc2bmBLEPS/bleps.substitutions, records from bleps_bi.db):
trips and warnings declare ZNAM="NO_FAULT" / ONAM="TRIP", faults and the
comms flag declare ZNAM="" (the empty string) / ONAM="Present". caget
returns those literal words. So every BLEPS channel resolved to None,
the comms flag included, which reads as a dark feed and suppresses the
whole observer: the supply half of #717 could not have recorded anything
at 2-BM. This answers BLEPS-4, which questions.md carried as
Blocks-go-live.

The index behind those labels is not free text. Every adapter already
held it at the moment it picked a label and then discarded it, so
Measurement gains an `ordinal` carried BESIDE the label, and binary_code
prefers it with the conventional labels kept as the fallback. Same
division quality (domain meaning) already makes with quality_detail
(substrate breadcrumb). The label stays load-bearing in its own right:
scan settings like ScanType are recorded as words, and that is the
meaningful fact there.

This generalizes where a wider dictionary could not. All four adapters
hand over an index; caproto already published one, so the fleet was
inconsistent and only binary_code's dual int/str handling hid it. MAX IV
and ESRF are Tango floors on the same growth ladder, and their labels
are theirs to choose.

Tango DevState is carved out. It has an ordinal, but it indexes Tango's
global device-state vocabulary (ON = 0, OFF = 1), so publishing it would
resolve an ON device to false. The carve-out keys on attr.type, the same
field _kind_for reads, not on a property of the value: a proxy check
would miss a DevState delivered as a bare int and would silently
withhold a DevEnum that happened to expose .name.

Also closes a fail-open the hoist would otherwise have spread: int(0.4)
truncates to 0, and 0 is a confirmed state to every caller, meaning
"open" on the polarity-inverted shutters. The beam lookup guarded that
at its own call site; the guard belongs in the shared decoder now that
five consumers share it, and it stays there as belt-and-braces.

Every guard is mutation-verified. Two of the new tests were decorative
when first written and are recorded as such in their docstrings: the
DevState test passed because its fake was not int-convertible rather
than because the carve-out fired, and the out-of-range test used labels
the fallback also rejects. Both now fail when their guard is deleted.
The soft IOC gains two records carrying BLEPS's real label pairs so the
proof runs over real DBR_ENUM rather than a shape no bi record produces,
which is how the same defect shipped twice before.

What this gives up is written into binary_signal's docstring rather than
left implicit: an unconventional label used to make a MIS-POINTED record
fail closed by accident, and it no longer does. That protection was a
coincidence, firing only when the wrong record had unusual labels,
missing every wrong record with conventional ones, and rejecting correct
records with unusual ones. The out-of-range check catches only a wrong
record resting outside 0/1. State-count hardening was considered and
declined here: it would couple the ordinal to label resolution, and its
independence from the label cache is what makes it correct on a cold
first read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The names this repo carried for 2-BM's BLEPS channels do not exist on
the beamline. operations.md stated that the EPICS PV is the PLC tag with
dots swapped for underscores, and every PV built from that rule was
wrong: PLC `Flow2.Below_Set_Point_Trip` is `FLOW2_TRIP`, not
`FLOW2_BELOW_SET_POINT_TRIP`. The rule holds for some tags (`GV1.Faulted`
really is `GV1_FAULTED`) and not others, which is what made it look
right.

The config example in `Settings.bleps_supply_channels` is the part that
mattered: it is the block a deployment copies, and every PV in it was
fictional, so BLEPS_SUPPLY_CHANNELS built from it would have bound
nothing and the observer would have sat silent with a valid-looking
config. Corrected to the real names, with the derivation warning next to
them.

Read off the running 2bmBLEPS IOC on arcturus 2026-08-23
(iocBoot/ioc2bmBLEPS/dbl-all.txt, and bleps.substitutions for the
tag-to-PV pairing). The sharpest example is one channel disagreeing with
itself: the trip is `FLOW2_TRIP` and its own warning is
`FLOW_2_UNDER_WRN`, with an underscore before the digit. No rule
produces both. Nothing here is derivable; it has to be read.

The PLC tag names elsewhere on the page were already right and are left
alone. What changed is the claim that you can get a PV name from one.

Also resolves BLEPS-4, which questions.md carried as Blocks-go-live.
Settled by reading the IOC rather than by asking staff: the flags are
`bi` records labelled NO_FAULT / TRIP, and the fault and comms flags
carry an EMPTY ZNAM with ONAM `Present`. That page now says so, and says
that CORA reads the index rather than any of those words, so nothing
further is needed from the beamline. The empty ZNAM is noted as worth
mentioning to staff, not as a fix CORA is waiting on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 23, 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
  _bleps_supply_observer.py 573
  _capture_observer.py
  _enclosure_permit_observer.py
  capture_watch_preflight.py
  apps/api/src/cora/operation/adapters
  control_port_beam_availability_lookup.py
  epics_ca_control_port.py
  epics_pva_control_port.py
  tango_control_port.py
  apps/api/src/cora/operation/ports
  measurement.py
  apps/api/src/cora/shared
  binary_signal.py
Project Total  

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

The diff-coverage gate on #718 failed on exactly the three lines an
adversarial review had already called unreachable, which is a pleasing
convergence: the review reasoned it from call order, CI measured it.

`_enum_ordinal` in the CA and PVA adapters wrapped its cast in
`try / except (TypeError, ValueError)`. That branch cannot execute.
`_to_reading` calls `_unpack_value` first, and its Categorical arm casts
the same object, so anything that will not cast has already raised out
of `read` before the ordinal is asked for. The guard was not defensive
depth, it was an untestable line asserting a behavior the module does
not have, and the docstring I wrote defending it was wrong on its own
terms. Both are now unguarded and typed `-> int`, failing the same way
as the cast one line earlier.

Tango is genuinely different and keeps its guard. Its `_unpack_value`
returns `value.name` early for any named value and never reaches its own
cast, so a `DevEnum` carrying a named-but-not-numeric value arrives at
`_enum_ordinal` uncast and the guard is the first thing to see it. That
path now has a test rather than an assertion: the reading keeps a usable
label and simply carries no ordinal, so a two-state consumer falls back
to the label. Mutation-verified, deleting the guard turns that one test
red and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xmap
xmap merged commit 12895b0 into main Aug 23, 2026
19 checks passed
@xmap
xmap deleted the fix/two-state-ordinal branch August 23, 2026 20:21
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