Skip to content

Wavelength lookup tables as streamed context inputs (ADR 0010) - #1235

Draft
SimonHeybrock wants to merge 21 commits into
mainfrom
wavelength-lut-context-input
Draft

Wavelength lookup tables as streamed context inputs (ADR 0010)#1235
SimonHeybrock wants to merge 21 commits into
mainfrom
wavelength-lut-context-input

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Aug 11, 2026

Copy link
Copy Markdown
Member

Feeds the wavelength lookup table computed by wavelength_lut_workflow back into the backend workflows that need it, replacing the tables loaded from files at import time. Those files describe a nominal chopper configuration, so running in any other configuration is silently wrong; the LUT workflow already computes the right table from the live chopper cascade, but publishes it as a result nothing consumes. This PR closes that loop for DREAM and LOKI.

The new concept is a workflow output republished as an input stream for other workflows. The NICOS derived-device mirror (ADR 0006) is publish-only and nothing consumes a mirrored topic, so this is the first cross-service feedback edge. ADR 0010 records the design; the commits implement it end to end.

What it does

The LUT workflow keeps its single chopper_cascade source and now publishes one table per component, each covering only that component's flight-path range, declared as context outputs on a dedicated topic. Consumers bind their component's table as gated context. On DREAM each table spans 2–11 distance rows at the default 0.1 m resolution, against roughly 730 for one table covering source to detector — the cascade is still computed once per trigger, and only the polygon rasterization runs per component.

The user-facing distance-range parameter is gone. It defaulted to a span covering no instrument correctly, so an operator starting the workflow with defaults could silently blank every detector: a lookup outside the table's range yields NaN, those events fall outside every histogram bin, and the component renders empty with no error anywhere.

Decisions worth pulling out

Ranges are derived, not declared. Each component's range runs essreduce's own DetectorLtotal and MonitorLtotal providers — the very ones the consumer runs at lookup time — so the range and the lookup agree by construction rather than by review. An earlier draft of the ADR had the Ltotal rule declared per component, justified by indirect geometry putting a table "tens of metres" from where it is queried. That magnitude was wrong: the secondary flight path is metres against primary paths of tens to hundreds of metres, so padding absorbs it and the declaration buys exactness the table's distance resolution does not reward. Over-padding costs recompute in the LUT job and nothing else; under-padding is silent.

Motion needs two numbers, not one. The geometry artifact stores a live f144-driven transform as an empty NXlog, so it withholds the component's resting position as well as its travel. Instruments declare both, as one MotionEnvelope per moving axis keyed by NeXus transform path; which components ride an axis stays derived, since a component is affected precisely when the axis appears in its depends_on chain. LOKI's carriage rests at 0 and travels 15 m, placing the rear bank at 28.5 .. 43.9 m.

A component that cannot be placed gets no table. beam_monitor_m4 rides its own undeclared axis, so the LUT workflow cannot place it and publishes nothing for it. Views and reductions bind only placeable components, so no job waits forever on a stream that is never published; an I(Q) aux selection that picks such a monitor is rejected at job creation with an error naming the monitor, rather than surfacing as a gate that never opens.

Coordinate mode stays a parameter on one workflow. Gating every job of such a spec on the table would make time-of-arrival — the mode you fall back to when everything else is broken — depend on an operator-started job that re-emits only on chopper change. ADR 0003 prescribed splitting the spec for exactly this case. That was implemented here and then reverted: the dashboard data plane is keyed by (workflow_id, source_name, output_name), so two specs give the two modes different output identities, a plot cannot follow a mode switch, the operator runs two jobs, and every per-instrument params override is duplicated. Instead a ContextBinding carries a predicate over the job's validated params, so a TOA job resolves an empty gating set. This supersedes ADR 0003's param-dependent-context non-goal, which was a YAGNI call made when the only over-gated stream was an always-on control-system PV.

A reduction's monitor tables are selected by aux-templated stream names. I(Q) needs one table per sciline Component — the detector plus the incident and transmission monitor roles — and which physical monitor fills a role is a per-job aux selection an import-time binding cannot name. The binding names the aux field instead: its stream name carries a placeholder (wavelength_lut/{incident_monitor}) that gate resolution renders against the job's rendered aux selections. One binding per role, a gate covering exactly the selected monitors' tables, and no dead keys. Route derivation expands a template over the aux field's declared choices, so the statically derived subscriptions stay a superset of any rendered gate; a selection resolving one stream to two conflicting keys (the same monitor for both roles) is rejected at job creation. This supersedes an earlier plan to bind the default monitors and raise when the selection differed — making the aux selector a lie in wavelength mode — and an intermediate shape that bound every candidate monitor to its own synthesized key and had the factory map the chosen ones onto the roles, leaving every unselected key a dead parameter and restating the candidate list in the factory.

Gate resolution moved

Params validation moves up into job creation and WorkflowFactory.create takes the validated model, so the order is validate → resolve gate → build, with one validation site rather than two that can drift. Resolution splits in two rather than taking an optional argument: declared_context_keys ignores predicates and leaves templates unrendered, serving the static callers (route derivation, the workflow visualizer) that have no job and specifically need the superset, while resolve_context_keys filters by predicate and renders templates for the job path, which therefore also receives the job's rendered aux selections. A predicate can only remove bindings and rendering only substitutes declared choices, so the statically derived Kafka subscriptions stay a conservative superset of any resolved gate.

The stream-name-to-key mapping is declared exactly once, on the binding: consuming factories contribute only the unconditional reassembly provider, and the resolved bindings are injected after creation. The conflict check this enables surfaced that Instrument.load_factories was not idempotent — repeated loading appended duplicate bindings whose synthesized chopper-setpoint keys are fresh objects per run — so it now no-ops on a loaded instrument.

Scope

DREAM and LOKI migrate; they are the only instruments offering wavelength mode today. The detector- and monitor-view factories lose their lookup-table filename argument outright rather than defaulting it, so a view has no file path left to fall back to, and LOKI's I(Q) reduction migrates with them. This also removes a pooch download from both instruments' startup path. What remains of LookupTableFilename is set explicitly by the unmigrated BIFROST and ESTIA reduction pipelines at their own call sites — an input, not a fallback.

Wavelength mode is also no longer offered where it cannot work. Every logical view on every instrument previously offered it in the UI and raised at job start, since they run on InstrumentDetectorSource, which carries no geometry. Those specs now use a time-of-arrival-only params model, and a test pins that any spec accepting wavelength can actually build a workflow in that mode.

Known gaps

beam_monitor_m4 has no wavelength mode until its axis and travel are declared. The identity coord and consumer clearing described in ADR 0010 are not implemented yet (#1248), so a new table does not currently reset accumulated statistics. The standing limitations the ADR records — nothing guarantees the LUT job is running, a backend restart loses the table, and the gate protects startup rather than steady state — all still apply.

Test plan

  • Full fast suite
  • Full suite including slow tests
  • End-to-end test running the real chain on DREAM: the LUT job computes per-component tables, they are extracted, serialized to da00, ingested back through the Kafka route, and reduced with by a wavelength-mode monitor job, with no file anywhere
  • Service-level test covering LOKI I(Q) gating on its detector's table and exactly the selected monitors' tables, and rejecting a selection whose table is never published
  • Dashboard launches against DREAM
  • Start the lookup-table workflow and confirm one output per component appears
  • Confirm a wavelength-mode view sits in WAITING FOR CONTEXT until the LUT job emits, then reduces
  • Confirm a TOA-mode view starts immediately with no LUT job running

SimonHeybrock and others added 21 commits August 11, 2026 07:17
Records the design for feeding the wavelength-LUT workflow's output back
into backend workflows as a context input, replacing the hard-coded
per-instrument LUT files.

Status is proposed; one open item (opt-in vs universal clear-on-change)
is still under discussion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sc.identical returns False for two bit-identical arrays containing NaN,
and lookup tables carry all-NaN rows wherever the chopper cascade blocks
the beam. As written the mechanism would have cleared every consumer on
every republish. Use sc.allclose(..., equal_nan=True) plus explicit coord
comparison, whose tolerance doubles as the noise-rejection knob.

Also records that motion and LUT clearing are orthogonal because the
range is static -- a property of the static-range decision that the
original text did not credit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparing table contents at the consumer put the noise-rejection knob far
from the setpoint jitter it filters, and needed N comparisons plus a
NaN-safe primitive. A fingerprint derived from the producer's inputs is
one scalar, survives a producer restart, and puts the rounding precision
at the source.

Resetting on any received LUT is simpler still but would clear every
consumer when the LUT job is restarted, which is the v0 recovery action,
and would make the planned liveness heartbeat clear the facility.

Also records consuming component motion in the LUT workflow as a
considered alternative: it would remove the static travel envelope, at
the cost of gating the LUT job on motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scope, v0 rollout and the instrument applicability matrix are plan
material, not decision record, and move to the implementation notes.
Line references are dropped throughout: they go stale, and the symbols
they pointed at are stable enough to name directly.

Standing limitations stay, reframed as consequences of the decision
rather than as things accepted for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coordinate mode is now a property of the spec rather than a runtime
parameter, for detector views and monitors alike. Gating is resolved per
(workflow_id, source_name) and never per parameter value, so once the
wavelength path consumes a lookup table as gated context a combined spec
would gate its TOA jobs on a table they never read (ADR 0010). TOA is the
mode you fall back to when everything else is broken; it must not depend
on the most fragile link in the chain.

DREAM and LOKI gain wavelength-variant detector-view and monitor specs.
The existing combined specs are untouched, so the file-based path stays
available to compare against the streamed one during commissioning.

Making "this spec cannot do wavelength" expressible exposed specs that
were already claiming otherwise. Every logical view offered wavelength in
the UI and raised at job start: they run on InstrumentDetectorSource,
which carries no geometry, so there is no Ltotal to index a table with.
Three geometric specs had the same defect. Monitor output templates
hard-coded a time_of_arrival coord, so a wavelength monitor output never
matched its declared model -- invisible until now because the validation
test only ever ran default parameters.

The new test pins the invariant behind all of these: a spec whose
parameters accept wavelength must actually build in wavelength mode.
…pecs

Reverts the spec split. Coordinate mode goes back to being a parameter on
a single workflow, because the dashboard's data plane is keyed by
(workflow_id, source_name, output_name): two specs give the two modes
different output identities, so a plot cannot follow a mode switch and
the operator runs two jobs where one would do. Coordinate mode describes
how you are looking at a detector, not which detector you are looking at.

The split existed to stop a wavelength lookup table, delivered as gated
context, from also gating time-of-arrival jobs that never read it. That
turns out not to require a split: the gating set is resolved at a single
call site inside job creation, which already holds the job's parameters,
so the gate can be narrowed per job. ADR 0003 named param-dependent
gating a non-goal on YAGNI grounds when the only over-gated stream was an
always-on control-system PV; both of those premises have expired. The
predicate itself is not built here -- it has no consumer until there is a
lookup-table binding to attach it to.

What survives the reversal is the part the split made visible: specs that
cannot convert to wavelength now say so, rather than offering a mode that
raises at job start.
The per-component Ltotal rule was justified by indirect geometry putting a
table "tens of metres" from where it is queried. The secondary flight path is
metres against primary paths of tens to hundreds of metres, so padding absorbs
the difference and the declaration buys exactness the table's distance
resolution does not reward.

Also pins the consumer seam to essreduce's public LookupTable dataclass rather
than the file loader's backwards-compatibility branch, and records the
DREAM+LOKI scope and the gate-resolution interface change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A workflow output named in WorkflowSpec.context_outputs is republished on a
dedicated topic under a stable, job-identity-free stream name, where it becomes
an ordinary context input for other workflows to bind (ADR 0010). This is the
publish half only; nothing consumes the topic yet.

Modelled on the NICOS derived-device mirror, but without its contract object:
context stream names need one query (which outputs does this job republish),
not a static export or dashboard lookups, so the registry is resolved once in
the extractor's constructor. That makes a colliding or malformed declaration a
startup failure rather than a surprise on the first result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the read half of the mirror: a dedicated topic per instrument, a da00
route carrying no stream lookup table so the internal stream name is the da00
source name, and a LatestValueAccumulator for the new kind in the detector and
reduction preprocessor factories. That accumulator is already marked as
context, so the context cache and the JobManager gate need no change.

Route derivation gathers a bound context stream name and then drops it, since
it appears in no stream lookup table. That is correct -- the topic is routed
unconditionally rather than derived from the mapping -- but it reads as a bug,
so it is pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A table is indexed by distance and a lookup outside its range yields NaN with
no error anywhere, so the range must be expressed in the same Ltotal the
consumer uses at lookup time. Rather than re-deriving that definition, the
range runs essreduce's own DetectorLtotal and MonitorLtotal providers, which
is what makes the range and the lookup agree by construction.

On DREAM each component spans 2-11 distance rows at the default 0.1 m
resolution, against roughly 730 for one table covering source to detector.

A component hanging off a live f144-driven transform raises instead of
guessing: the artifact carries no nominal value for it, and the alternative is
a table silently placed at the wrong distance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow keeps its single chopper_cascade source and now publishes one
table per detector and monitor, each covering that component's own flight-path
range, declared as context outputs so consumers can bind them. The user-facing
distance range parameter is gone: it defaulted to a span that covered no
instrument correctly, and an operator starting the workflow with defaults could
silently blank every detector.

The cascade is still computed once per trigger; only the polygon rasterization
runs per component, over a handful of distance rows instead of hundreds.

The integration tests no longer assert finite wavelengths in a table. Every
chopper is fed the same placeholder 14 Hz / zero delay, which blocks the beam a
few metres past the first chopper, so a table at a real component's distance is
legitimately all-NaN. Those assertions only passed before because the fixed
5-30 m range reached upstream of the first chopper, where nothing is blocked
yet. The cascade-bands diagnostic carries the assertion instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coordinate mode is a parameter, so one spec serves both time-of-arrival and
wavelength. Gating every job of such a spec on a lookup table would make TOA --
the mode you fall back to when everything else is broken -- depend on an
operator-started job that re-emits only on chopper change. A ContextBinding can
now carry a predicate over the job's params, so a TOA job resolves an empty
gating set while a wavelength job gates on its table.

Params validation moves up into job creation and WorkflowFactory.create takes
the validated model, so the order is validate -> resolve gate -> build with one
validation site rather than two that can drift.

Resolution splits in two rather than taking an optional params argument.
declared_context_keys ignores predicates and serves the static callers -- route
derivation and the workflow visualizer -- which have no job and specifically
need the superset; resolve_context_keys filters it for the job path. A
predicate can only remove bindings, so the statically derived Kafka
subscriptions stay a conservative superset of any resolved gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detector and monitor views bind their component's table as gated context and no
longer take a lookup-table filename at all: the argument is gone from both
factories, so a view has no file path left to fall back to. The table is
reassembled into essreduce's public LookupTable dataclass from the wire coords
rather than round-tripped through its file loader, whose matching branch is a
backwards-compatibility shim that cannot carry chopper provenance.

The context key is wired unconditionally while the binding's predicate alone
decides gating. Branching in both places would mean two conditions that have to
agree, with nothing to catch a disagreement; a context key no provider reaches
is a verified no-op.

LOKI I(Q) keeps its file table for now. Unlike a view it needs one table per
sciline Component -- the detector plus the incident and transmission monitor
roles -- and which monitor plays each role is a per-job aux selection that a
spec-scope binding declared at import time cannot know. Migrating it needs the
job-creation check ADR 0010 leaves open.

The end-to-end test runs the real chain on DREAM: the LUT job computes
per-component tables, they are extracted, serialized to da00, ingested back
through the Kafka route, and reduced with by a wavelength-mode monitor job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three related changes.

A component riding an f144-driven axis has no position in the geometry
artifact, which stores such a transform as an empty NXlog: neither its resting
value nor its travel can be recovered. Both become instrument declarations, one
MotionEnvelope per axis keyed by NeXus transform path. Which components ride an
axis stays derived -- a component is affected precisely when the axis appears in
its depends_on chain -- so one hung off it later inherits the envelope instead
of silently getting a nominal-only range. LOKI's carriage rests at 0 and
travels 15 m, which places the rear bank at 28.5 .. 43.9 m.

Consumers now bind only components the lookup-table workflow can actually
place. Binding a stream that is never published would leave the job gated
forever, and doing it for a component nobody asked about would take unrelated
jobs down with it -- which is exactly what beam_monitor_m4, on an undeclared
axis, would have done to every LOKI I(Q) job.

I(Q) takes its tables from the stream too. It needs one per sciline Component,
and which monitor fills the incident or transmission role is a per-job aux
selection that an import-time binding cannot know. So every candidate monitor
binds its own key and the factory, which does see the selection, maps the
chosen ones onto the roles; the unselected keys are dead parameters that
set_context stores and computes nothing from. Gating on all candidates costs
nothing because they all come from one job, split out of one result, so they
arrive together. This retires ADR 0010's proposed job-creation check that would
have raised when the chosen monitor differed from the bound one.

The wavelength-mode predicate no longer tolerates a params model without a
coordinate mode. Reading the missing field as "not wavelength" left I(Q)
ungated and handed its providers a table that never arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three corrections of record.

A live transform is stored as an empty NXlog, so the artifact withholds the
component's resting position as well as its travel; both are declarations, one
MotionEnvelope per axis keyed by transform path. Which components ride an axis
is derived from their depends_on chains rather than from chain_patch_bindings,
which is the honest source and covers components no binding mentions.

A component on an undeclared axis gets no table and is bound nowhere, rather
than gating a job on a stream that is never published.

Binding every candidate monitor replaces the planned job-creation check on the
aux selection: the tables all come from one job and arrive together, so gating
on all of them opens the gate at the same instant as gating on one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The consuming factories repeated the lookup table's stream-name-to-key
mapping that the spec's ContextBinding already declares: the detector
view and the monitor workflow set it as a constructor context key, and
LOKI's I(Q) listed every candidate table again. The copies were
redundant -- WorkflowFactory.create injects the resolved bindings via
SupportsContext.build before the StreamProcessor is baked -- and dead
for TOA jobs, whose predicate-filtered gate never delivers the stream.
Worse, they were a second declaration that had to agree with the
binding with nothing to catch a disagreement: the routing-vs-wiring
drift ADR 0003 was written to remove, reintroduced in a new spot.

The factories now contribute only the reassembly provider, still
inserted unconditionally; a provider whose input never arrives is dead
graph. All stream-name knowledge lives on the binding.
Which monitor fills the incident or transmission role is a per-job aux
selection, which an import-time ContextBinding could not name. The
previous shape bound every candidate monitor to its own synthesized
context key and had the factory map the chosen ones onto the roles:
dead parameters for the unselected keys, a fresh-NewType-per-call
footgun in component_lut_context, and the candidate list restated in
the factory.

A binding's stream name may now carry an aux-field placeholder
(wavelength_lut/{incident_monitor}), rendered against the job's
rendered aux selections during gate resolution, which already runs at
the one call site holding them. One binding per role, a gate covering
exactly the selected monitors' tables, and no per-candidate keys
anywhere. declared_context_keys leaves templates unrendered; route
derivation expands them over the aux field's declared choices so the
statically derived subscriptions stay a superset of any rendered gate.
Two roles selecting one monitor would resolve one stream to two
conflicting keys and are rejected at job creation; a monitor whose
table the LUT workflow cannot publish is rejected by the I(Q) factory,
since a gate on it would wait forever (previously such monitors were
silently left unbound).

The new conflict check surfaced that Instrument.load_factories was not
idempotent: every call re-appended context bindings, and the
synthesized per-chopper setpoint keys are fresh objects on each run, so
repeated loading (as pytest collection does) left duplicate bindings
with unequal keys that the old dict comprehension silently collapsed.
It now no-ops on a loaded instrument.

Also drops the stale claim that chopper provenance already travels in
the table's identity; the identity stamp is specified in ADR 0010 but
not yet implemented.
The both-modes and TOA-only variants of the detector-view and monitor
params models each restated the toa_range/toa_edges field pair, and the
copies had already drifted in their descriptions ("in TOA mode" vs
plain). One fields-only mixin per module now carries the pair; the
descriptions unify to the mode-neutral wording, which is correct for
both variants. get_active_edges/get_active_range stay on the concrete
models: a mixin answering "what is active" with TOA would let a future
both-modes model silently inherit the wrong answer.

Field order, titles, and defaults are unchanged, so the rendered UI
forms and serialized params are identical apart from the description
strings.
The DREAM and LOKI detector-view wrappers existed to resolve a
lookup-table filename before delegating; with the table streamed as
context they had decayed to pure passthroughs. Attach make_workflow
itself: attach_factory infers the params model from the signature, and
the spec-registered params are subclasses of DetectorViewParamsBase.
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