Skip to content

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

Merged
SimonHeybrock merged 43 commits into
mainfrom
wavelength-lut-context-input
Sep 8, 2026
Merged

Wavelength lookup tables as streamed context inputs (ADR 0010/0011)#1235
SimonHeybrock merged 43 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 publishes two tables — one for the detectors, one for the monitors — declared as context outputs on a dedicated topic. Consumers ask for their group's table by inserting one provider, and select their own rows from it.

Two rather than one per component, because a table is a function of distance and event_time_offset alone and carries no component identity: a per-component table is merely a restriction of the same function. What varies across components is only which stretch of beamline must be covered, so components that share a stretch share a table with nothing lost. Two rather than one overall, because a monitor job has no use for the detectors' dense rows, which are the large payload.

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

A table carries rows only where components are. Neither table is a single uniform grid — monitors sit tens to hundreds of metres upstream of the detectors — so a table is a concatenation of uniform blocks: one dense block across the detectors, one per monitor. LOKI's four monitors cost twenty-six rows in total. Empty rows are not free: BIFROST's 155 m span at a 0.1 m resolution is 1550 rows of 286 event-time-offset bins, a 3.5 MB message against a broker's 1 MB default, and 35 MB at 0.01 m, to carry what fits in tens of kilobytes.

The concatenation is deliberately not uniform, and essreduce's interpolator_numba assumes uniformity — it locates a row by dividing, reading the wrong row silently, and only under numba, since the scipy fallback handles an uneven axis correctly. A consumer therefore selects its own block before the table reaches essreduce, matching the Ltotal its graph already computes against the distances the table already carries. Component identity stays off the wire entirely. The boundary between blocks is stated, in a block coord, rather than inferred from where the row spacing jumps: inferring it is a threshold on a float difference, tuned against the padding the table builder happens to add, and it would forbid the producer from ever emitting two blocks that overlap or abut, which two nearby monitors otherwise would.

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 is declared as an axis range, not a displacement. The geometry artifact stores a live f144-driven transform as an empty NXlog, so the component riding it has no position at all until something supplies an axis value. Instruments declare one AxisRange per moving axis, keyed by NeXus transform path. Both bounds are axis values in the axis's own units, so the transform keeps supplying the direction and sense of the motion: the range comes from evaluating the geometry at the bounds, not from assuming which way along the beam the axis travels. 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 runs from 0 to 15 m, placing the rear bank at 28.5 .. 43.9 m. Translations are bracketed exactly where it matters — pixel positions are affine in the value, so Ltotal is convex and its maximum sits at a corner of the box the bounds span, and padding absorbs the interior minimum a component crossing the sample plane could have. An angle has no such property, so a live rotation axis is refused rather than approximated and its component falls into the no-table case below.

A component that cannot be placed gets no block. beam_monitor_m4 rides its own undeclared axis, so the LUT workflow cannot place it and lays out no rows for it. A group with no placeable component at all publishes no table, so its stream is never declared and a job asking for it fails at creation rather than waiting forever. Where the group is fine but one component is not, a job on that source gates like any other and then fails at its first recompute, reporting its flight path against the table's coverage. An I(Q) aux selection that picks such a monitor is rejected at job creation instead, with an error naming the monitor: there the selector is the thing at fault, and the job's own source may well be placeable.

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 the gate is derived from the graph the params build, so nothing states the condition at all: the factory inserts the reassembly provider unconditionally, a TOA job's graph reduces straight from event_time_offset and leaves that provider on a branch no target reaches, and the job therefore requests nothing and gates on nothing. 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.

Both of a reduction's monitor roles read the one monitor table. I(Q) needs a 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. Sharing the monitor table dissolves the problem rather than solving it: one stream, and one provider generic in MonitorType that sciline instantiates per role, each instance selecting its rows by that role's own MonitorLtotal. Which monitor fills a role is settled by geometry the job already holds, not by the stream it reads.

This supersedes three earlier shapes: binding the default monitors and raising when the selection differed, which made the aux selector a lie in wavelength mode; binding every candidate monitor to its own synthesized key, leaving the unselected ones dead parameters; and aux-templated stream names (wavelength_lut/{incident_monitor}), where the binding named the aux field and gate resolution rendered it against the job's selection. That last mechanism worked and was the most intricate thing in the PR — declared-versus-resolved names, route derivation expanding a template over an aux field's declared choices to keep subscriptions a superset. It has no user left and is removed, on both sides of the mirror: stream names are plain and fixed at declaration time again, which is what the statically derived Kafka subscriptions and ADR 0006's no-job-identity rule both want.

The gate is derived, not declared

Whether a job reads the table used to be stated twice: the factory inserted the reassembly provider, and the instrument declared a ContextBinding naming the specs, sources and params that gate on it. Two statements of one fact, which can disagree — and the silent direction of the disagreement leaves a job in pending_context forever, waiting for a stream it would never read. The declaration form also could not express a spec reading another group's table without contortion: LOKI's I(Q) had to bind the monitor table against its detector sources, because the monitors arrive as an aux selection and are not the jobs' source names.

The graph already knows. An instrument now only offers a stream under the workflow key it fills (Instrument.offer_context_stream), naming no spec, source or params, and StreamProcessorWorkflow.build keeps a declaration when its key is an ancestor of a target key — the exact condition under which finalize would fail without it. Reachability rather than insertion is what makes this work for a params-dependent mode: a provider the targets cannot reach is pruned, so the factory inserts unconditionally and the params decide. Job creation then reads the result back off the workflow, so the order becomes validate → build → gate; nothing consumed the gating set before the build, so the reordering costs nothing.

The two mechanisms divide cleanly, but not along push-versus-pull, which is where an earlier draft of this PR drew the line. An offer is derivable whenever the graph names the key, so a binding is required in three cases only: the key reaches the graph solely because the binding injects it (a chain patch is never mentioned by the graph, so there is no request to read back), the stream filling a key varies per source (offers are bijective instrument-wide), or the stream is private to one spec. That last one is a routing constraint rather than a semantic one: route derivation runs at startup with no params while an offer is taken up per job from the built graph, so every service hosting any spec must subscribe to every offered stream. The wavelength LUT's own per-chopper setpoints stay bindings for that reason — they are consumed by one workflow on the timeseries service, and offering them would add twelve dead subscriptions each to data_reduction, detector_data and monitor_data on BIFROST.

Motion is therefore not automatically on the binding side. BIFROST's tank and sample rotations are ordinary parameters of the cut workflow, so they are offered too, and the two skip_instrument_contexts() calls that used to restate graph reachability by hand — the detector view sums over banks, the ratemeter is counts-only — are gone with them. Route derivation gathers offered stream names alongside binding names, which it did not need to do while every offer was published on the context topic and dropped again; without it, moving BIFROST silently unsubscribed data_reduction from the motion topic. An offered name is also checked at registration against what anything actually publishes, the check bindings already had: a gate on a name nobody publishes never opens.

ContextBinding.predicate loses its only user and is removed, along with the two-method split that existed only to filter by it. Params validation still moves up into job creation and WorkflowFactory.create still takes the validated model, so there is one validation site rather than two that can drift.

The stream-name-to-key mapping is declared exactly once either way: consuming factories contribute only the reassembly provider. The registration-time conflict check 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.

Unrelated but adjacent: scripts/visualize_workflows.py called WorkflowFactory.create without params and so failed to create every workflow it tried to render, swallowing the TypeError per workflow. Fixed here since the same call site needed the new argument.

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 range is 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 both tables, they are extracted, serialized to da00, ingested back through the Kafka route, and reduced with by a wavelength-mode monitor job that selects its own block, with no file anywhere
  • Service-level test covering LOKI I(Q), created through JobFactory, gating on both tables whichever monitors it selects, its monitor roles selecting their blocks by flight path, and a selection whose monitor has no block being rejected
  • Unit tests for the derivation itself: a declared stream is taken up and wired when the targets reach its key, ignored when the provider is unreachable, and a key the graph needs that nobody declared fails the build
  • BIFROST's rotation gate, now derived rather than declared: the three Q-maps gate on both rotation streams, the detector view and the ratemeter on neither
  • DREAM detector_projection jobs created in both modes: wavelength gates on wavelength_lut/detectors, TOA gates on nothing, ROI aux routed to the job in both and gating in neither
  • Dashboard launches against DREAM
  • Start the lookup-table workflow and confirm the detector and monitor tables appear, the monitor one plotted as "Overlay 1D" (its distance axis is not uniform)
  • 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 28 commits August 26, 2026 10:09
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.
Widening the transmission choices to M3/M4 made an option selectable
that a wavelength reduction cannot honour. The geometry artifact drives
beam_monitor_m4's position from a live f144 NXlog but records its source
as the literal string 'source' rather than a PV -- the only stream in the
LOKI artifact with that defect -- so the monitor cannot be placed, gets
no lookup table, and the I(Q) factory rejects it.

That rejection is correct and already tested, but it lands only after the
user has configured and started a job. State the limitation where the
workflow is chosen instead. It goes on the spec description because the
aux selector renders only AuxInput.title, so an AuxInput.description
saying the same thing would never be seen.

A MotionEnvelope is deliberately not the fix here: it would make the
range derivable and let the job build, only to fail deeper in the
reduction on the unpatched time-dependent transform, since a chain-patch
binding needs the PV name the artifact does not carry.

Also pin that the monitor defaults stay placeable, so changing a default
cannot break I(Q) for anyone who never touches the selectors, and repair
a garbled sentence in the same description.
Services now consume <instrument>_livedata_context, but ensure_topics_exist
enumerated the infrastructure topics by hand and was not updated, so every
integration test failed on a pristine broker with "Topics not found:
['dummy_livedata_context']".

Derive the set from the LivedataTopics fields instead, so a new topic cannot
leave topic creation behind again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MotionEnvelope named a `nominal` that NXtransformations has no concept of: the
artifact stores the live transform as an empty NXlog, so its geometry
corresponds to no axis value at all and `nominal` was only the lower end of the
interval. `travel` was worse than its name -- declared in metres and added
straight onto the Ltotal stop, it silently assumed the axis vector runs along
the beam and that increasing values move downstream. LOKI's carriage happens to
satisfy both.

AxisRange declares both bounds as axis values in the axis's own units, so the
transform supplies the direction and sense of the motion: the range comes from
evaluating the geometry at the corners of the box the bounds span, not from
assuming which way along the beam the axis travels. The same two numbers, now
in the same space.

Pixel positions are affine in a translation's value, so Ltotal is convex and
its maximum is attained at a corner; padding absorbs the interior minimum a
component crossing the sample plane could have. An angle has no such property,
so a live rotation axis is refused rather than covered approximately, and its
component falls into the existing no-table path instead of getting one too
narrow for its swing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Match DeviceContract.devices_for, which looks up by the WorkflowId object.
The string form was internal to this module, so keying on the model itself
drops the per-result f-string from the extractor's hot path.
The 0-D coords carrying the LookupTable dataclass's scalar fields were
taken from the job's parameters, on the reasoning that this keeps units
user-facing. But essreduce defines those fields as properties of the
table that was built ("Resolution of the distance coordinate in the
lookup table"), not as a record of what was requested, and the two
differ: the builder fits a whole number of bins into the frame period,
so a requested 250 us becomes 249.75 us at the default 14 Hz.

pulse_stride already had to be excepted from the params rule when
chopper-based auto-detection made params wrong. That was the same
failure, fixed pointwise; this drops the rule instead. Input provenance
belongs to the identity coord (ADR 0010), which leaves these coords free
to describe the table alone.

No behaviour change: the two resolutions are write-only in essreduce,
read by nothing downstream.
A lookup table is a function of distance and event_time_offset alone, so a
per-component table is merely a restriction of the same function. Publishing N
of them cost an outputs model generated per instrument, a stream, binding and
sciline key per component, a dashboard plot list that grew with the instrument,
and per-job monitor selection expressed in stream names.

Components that share a stretch of beamline now share a table: one for the
detectors, one for the monitors. Neither is a single uniform grid -- monitors
sit tens to hundreds of metres upstream -- so a table is a concatenation of
uniform blocks, dense across the detectors and a few rows at each monitor. LOKI's
four monitors cost 26 rows in total.

essreduce's numba interpolator locates a row assuming a uniform axis, so a
consumer selects its own block before the table reaches essreduce, matching the
Ltotal its graph already computes against the distances the table already
carries. Component identity therefore leaves the wire entirely, and with it the
aux-templated stream names: both I(Q) monitor roles bind the one monitor stream
and a provider generic in MonitorType serves every monitor role.

Also drops component_ltotal_ranges, whose last caller was its own test.
A ContextBinding's stream name could carry a {aux_field} placeholder that gate
resolution rendered against the job's aux selections, with route derivation
expanding it over the field's declared choices so Kafka subscriptions stayed a
superset of anything a job could render. Its only user was the I(Q) reduction
picking the streamed lookup table of whichever monitor filled its incident or
transmission role; sharing one monitor table removed that need -- a role now
selects its rows by flight path -- and nothing else templated a stream name.

Stream names are therefore fixed at declaration time again, which is what the
subscription derivation and the no-job-identity rule (ADR 0006) both want.
resolve_context_keys loses its aux_source_names parameter, and its
conflicting-key check now reports what remains possible: two bindings naming
one stream for different purposes, which is a declaration mistake rather than
something a job's selection can cause.
A context_outputs entry was a stream-name template, rendered once per source
name the publishing spec declares. Nothing rendered one: the only declaration in
the tree names two fixed streams on a one-source spec, and the placeholder was
exercised by its own tests alone.

The consuming side lost the same mechanism in the previous commit, for a reason
that applies here too. A context stream carries no job identity (ADR 0006), and
a name that varies per job is a name the statically derived Kafka subscription
cannot be sure to cover. A spec publishing context outputs therefore has exactly
one source name -- until now enforced by accident, as the duplicate-name check
firing on a spec colliding with itself, and now a WorkflowSpec validator that
says so and fails at registration.

device_outputs keeps its templating: a NICOS derived device is named per source
by design, and nothing consumes the name back.
Two bindings naming one stream for different Sciline keys was detected while
resolving a job's gate and reported per job. Nothing about a job can cause it
any more: since stream names stopped being templated, the bindings that apply to
a (spec, source) pair are a function of the declarations alone, and a predicate
only ever removes one. Worse, a predicate that removed one of the conflicting
pair hid the conflict from the check entirely.

It joins the collision checks already run over every (spec, source) pair at
registration, which leaves declared_context_keys and resolve_context_keys as the
same comprehension, one of them filtered.
attach_wavelength_lut_factory returned every derived flight-path range keyed by
component, and Instrument kept the mapping only to take its keys: with one table
per group the ranges have no reader outside the factory that lays the blocks
out. Deriving them per group rather than into one mapping by name also drops the
regrouping the caller did immediately afterwards.
Packing the blocks into the published DataArray lived with the producer and
unpacking one job's block with the consumer, each spelling out the four scalar
LookupTable fields the wire carries as coords. Two tests reached into the
producer's private function to build a table at all.

pack_blocks and unpack_block are inverses, so they now sit together in
lut_blocks -- which already owns the block invariant both depend on -- with one
list of fields between them.
A consumer recovered the blocks by looking for where the row spacing jumps: a
threshold on a float difference (_SPLIT_FACTOR), which the producer kept
satisfiable by merging any two ranges closer than a second, larger threshold
(_MERGE_FACTOR) -- larger because the upstream builder pads every block by two
resolution steps at each end, so rows sit four steps closer than the ranges they
came from. Three quantities had to stay in step, and a test fixture had to claim
a 10 km distance resolution to make a two-row table read as one block.

The table now carries a block coord, so a boundary is stated rather than
inferred. Merging goes with the inference: two monitors close enough to overlap
simply get a block each, costing a few duplicated rows, and a job takes the
first block covering its flight path.

The blocks themselves stay. Rows only where components are is what keeps the
message small: BIFROST's 155 m span is 3.5 MB at a 0.1 m resolution against a
broker's 1 MB default, and 35 MB at 0.01 m, to carry what fits in tens of
kilobytes.
Every view passed the group whose table it binds -- detector views the
detectors, monitor views the monitors -- so the argument said something only at
LOKI's I(Q), which binds the monitor table on its detector sources because its
monitors arrive as an aux selection. It now defaults to the group, leaving that
one call as the only place the two differ.
@SimonHeybrock
SimonHeybrock force-pushed the wavelength-lut-context-input branch from 2b64edf to 0613c98 Compare August 26, 2026 08:09
Whether a job reads the streamed lookup table was stated twice: the factory
inserted the reassembly provider, and the instrument declared a ContextBinding
saying which specs, sources and params gate on it. Two statements of one fact
that can disagree -- and the silent direction of the disagreement leaves a job
in pending_context forever, waiting for a stream it would never read. The
declaration form also could not express a spec reading another group's table
without contortion: LOKI's I(Q) had to bind the monitor table against its
*detector* sources.

The graph already knows. An instrument now declares only which stream carries
which workflow key, and StreamProcessorWorkflow.build keeps a declaration when
its key is an ancestor of a target key -- the exact condition under which
finalize would fail without it. Insertion stays unconditional: a provider the
targets cannot reach is pruned, so a time-of-arrival job requests nothing and
gates on nothing, with no predicate and no conditional insert saying so. Job
creation reads the result back off the workflow, so the order becomes validate
-> build -> gate.

ContextBinding.predicate loses its only user and goes, along with the
declared/resolved split that existed to filter by it. The two mechanisms now
divide cleanly: a binding is pushed into a graph that never names it (a chain
patch), a declared stream is pulled by a graph that asks for its key.

Also fixes visualize_workflows, which called WorkflowFactory.create without
params and so failed to create every workflow it tried to render.
The tank and sample rotation keys are ordinary parameters of the cut
workflow, so which jobs wait on them follows from the graph each job
builds. Declaring them as bindings meant restating that by hand:
skip_instrument_contexts() on the detector view and the ratemeter,
maintained in a different file from the graphs it describes. Offering the
streams by key derives the same split -- verified against the real
workflows: the three Q-maps reach both keys, the two opt-outs do not.

A binding is now required only where the key reaches the graph solely
because the binding injects it (a chain patch), or where the stream
filling a key varies per source. Offers are bijective and cannot express
the latter; that, rather than push-vs-pull, is where the line falls.

Renamed accordingly, since the previous vocabulary gave four near-synonyms
on one axis and two inverse maps with near-identical names:

  declare_context_stream  -> offer_context_stream
  Instrument.context_streams (key -> wire name)
                          -> offered_context_streams
  declared_context_keys (wire name -> key)
                          -> bound_context_keys
  core.context_outputs.resolve_context_streams
                          -> resolve_context_outputs

Route derivation now gathers offered stream names too. It only ever saw
binding names, so moving BIFROST dropped the motion topic subscription
from the reduction service -- caught by the service-level gating tests.

Offered names are validated at registration against what anything
actually publishes, the check bindings already had: a gate on a name
nobody publishes never opens.
Offers cannot be routed precisely: route derivation runs at startup with
no params, while an offer is taken up per job from the built graph, so
every service hosting any spec subscribes to every offered stream. The
wavelength-LUT chopper setpoints are consumed by one workflow on the
timeseries service; offering them would add 12 dead subscriptions each to
data_reduction, detector_data and monitor_data on BIFROST.

The previous docstring listed only injection and per-source variation as
reasons to prefer a binding, which reads as an invitation to move them.
The ROI work on main (#1269) reached the same gating machinery from the
other side, so the two halves of the gate had to be reconciled:

- ROI leaves AuxSources for a non-gating ContextBinding, so the branch's
  aux_source_names plumbing through the detector-view factory and the
  per-instrument factory wrappers goes with it.
- JobFactory now unions the binding-declared gate with the streams the
  built graph asks for, instead of taking all bound context keys. The
  routed set keeps every bound context stream, gating or not.
- resolve_gating_streams is renamed bound_gating_streams to pair with
  the branch's bound_context_keys, against offered_context_streams.
- register_detector_view absorbs the spec registration add_logical_view
  used to inline. Logical views keep their TOA-only params, now stated
  once in add_logical_view rather than per instrument; MAGIC's geometric
  projection still passes them explicitly, as it has no lookup table.

The seam the merge creates -- a job with both a non-gating binding and a
requested offered stream -- is pinned by a new job-manager test.
The amendment described a design that was never built: a gating predicate
on ContextBinding, and spec-scope stream names carrying aux-field
placeholders rendered from a job's aux selections. Both were removed
before the branch settled -- the gate is derived from the built graph,
and a context stream name is fixed at declaration time, as the
stream_name docstring says. It also claimed the spec split had been
rejected in favour of the precise gate, when the split was tried and
reverted for an unrelated reason: two specs give the two coordinate modes
different output identities.

What ADR 0003 needs here is a pointer, not a restatement. Its non-goal
still holds as written -- a ContextBinding gates every job on its
dependent sources regardless of params, motion included -- so the
amendment now says only what ADR 0010 adds alongside it, and where the
reasoning lives.
@SimonHeybrock
SimonHeybrock marked this pull request as ready for review September 4, 2026 07:08

@nvaytet nvaytet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't look at everything yet, but I have a preliminary question.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this ADR much too long. When reading it, it's difficult to make sure we have not missed important bits, as one's focus drifts away half way through.
My guess is that it comes from Claude's OCD to leave no stone un-turned, but there is no way I can fit all this into my head in one go.
ADRs used to be relatively brief and to the point. I don't find keeping in the docs such ADRs useful because no one will read them (apart from AI agents maybe? or maybe that was the point, keep them for a future agent?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I had reviewed the full initial version, but then went through several update rounds where I just looked at amendments, each of which grew it. I'll see how it can be trimmed down (I suppose a lot of the details are just documented in the code itself).

detector table and a monitor table. The range parameter is removed. One message per output
follows automatically: `UnrollingSinkAdapter` already splits a multi-output result.

Two, rather than one per component, because a table is a function of `distance` and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In scipp/ess#603 we make one table per component. I don't see an issue in making one for each monitor, apart from maybe displaying the results?
But it would be nice if the workflow here was functioning in the same way as the GenericUnwrapWorkflow?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did that in an earlier version of the branch, but it came with several problems.

``int((ltotal - first) / (distance[1] - distance[0]))``, which reads the wrong
row -- silently, and only under numba, since the scipy fallback handles an
uneven axis correctly. A consumer must therefore select its own block with
:func:`select_block` and never hand a whole multi-block table to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we concatenating if this causes special handling later? Can't we make one uniform table per component and then not have to worry beyond that? Or is there an issue with publishing many tables?

If we want to publish just a single thing, can we publish a DataGroup? or maybe we have no schema for a DataGroup on kafka?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-component tables caused complications when wiring back into the workflows. I did this on an earlier version of the branch. It also complicated plotting of the tables.

Indeed, we do not have a schema fro DataGroup.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-component tables caused complications when wiring back into the workflows.

Can you elaborate a little on what were the problems?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things, all of which the branch went through before the block layout.

Wiring it into LOKI's I(Q). Two mechanisms meet badly there, so a bit of setup:

  • When you start an I(Q) job you pick, from dropdowns, which physical monitor acts as the incident monitor and which as the transmission monitor -- LOKI has four candidates. These are "aux selections": the job's own source is a detector bank, and the monitors are auxiliary inputs chosen per job.
  • A streamed context input is declared the other way round: a ContextBinding says, at import time, "this workflow's key X is filled by the stream named Y", and a job with such a binding waits (pending_context) until that stream arrives. It is a property of the workflow spec, fixed before any job exists.

With one table per component, the stream a job needs -- wavelength_lut/loki_monitor_1 versus ..._2 -- depends on a choice made at job creation, which the import-time declaration cannot know. Three shapes were tried and committed in turn:

  1. Bind the two default monitors and raise at job creation if the operator picked others. That makes the dropdown a lie in wavelength mode.
  2. Bind all four candidates, each to its own key, and let the factory (which does see the selection) map the chosen two onto the incident/transmission roles. The two unselected keys become parameters the workflow stores and never computes from, and the candidate list ends up restated in the factory.
  3. Let a binding's stream name carry a placeholder -- wavelength_lut/{incident_monitor} -- rendered against the job's actual selection when the gate is resolved. This works, but the name is now declared-but-unrendered in one place and rendered in another, and the Kafka subscriptions (derived at startup, before any job exists) have to be expanded over every declared choice so they stay a superset of whatever a job might render. It was the most intricate mechanism in the PR.

One shared monitor table removes the question instead of answering it: there is a single stream, one provider generic in MonitorType that sciline instantiates per role, and each role picks its rows by the flight path its own graph already computes. Which monitor fills a role is settled by geometry the job already holds, not by the name of the stream it reads. Stream names go back to being fixed at declaration time, which is what the statically derived subscriptions want anyway.

Plotting. With one table per component, the tables appear as N outputs of the LUT workflow. The plot wizard lets you multi-select sources in one step, but pick only one output (a radio button in step 1) -- so comparing components meant adding one layer per table, three wizard steps each, and LOKI's output dropdown was fourteen entries deep with an arbitrary one auto-selected. That asymmetry isn't an oversight: every source of a workflow shares the same output template, so sources are homogeneous by construction and safe to overlay, whereas outputs are different quantities and nothing guarantees they combine. Making outputs multi-selectable would mean building that missing check (intersecting compatible plotters and windowing options, deciding what a mixed-output table column means, and fixing legend labelling, which today counts outputs per cell and would silently collapse every curve to one label). So I built the other fix instead -- #1249, where a result carries the component it is about, putting the tables back on the source axis. It worked; I closed it because the block layout made it unnecessary.

Bookkeeping. The workflow's outputs model had to be generated per instrument with pydantic.create_model (the last such case in the codebase), component names doubled as output field names, and every component cost a stream, a binding and a sciline key.

Underneath all three: a lookup table is a function of distance and event_time_offset alone and carries no component identity, so a per-component table is only a restriction of the same function. The split bought nothing in content -- which is what made it worth paying the block-selection cost to remove. ADR 0011's alternatives table has the short version.

ADR 0010 had grown to 434 lines and 5600 words, more than twice the
next-longest record, and read as three documents in one: a decision
record, a mechanism explanation that the module docstrings of
lut_blocks, lut_ranges, lut_context and context_outputs now carry
nearly verbatim, and a history of the shapes the branch tried and
reverted. The index also asks for one decision per ADR, and this one
held two.

ADR 0010 now records the reusable decision: a workflow output
republished as a context input stream, requested by Sciline key and
gated from the built graph, with the per-job parameter-dependent gate
that amends ADR 0003. Its standing limitations are stated generically,
since no liveness and no replay apply to any context output, not just
the lookup table.

ADR 0011 records the lookup-table layout: two tables in blocks, ranges
derived from the geometry artifact and padded for motion, block
selection by flight path. Mechanism detail points at the module that
owns it instead of repeating it, and every rejected option is argued
once, in the alternatives table.

The identity-coord clearing is not implemented on this branch. It stays
as a deferred paragraph pointing at issue 1248, which already carries
the design, and its rejected alternatives stay in the table because the
issue does not record them.

Two test docstrings still described superseded shapes (separate specs
per coordinate mode, a per-spec ContextBinding for the table).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@SimonHeybrock SimonHeybrock changed the title Wavelength lookup tables as streamed context inputs (ADR 0010) Wavelength lookup tables as streamed context inputs (ADR 0010/0011) Sep 4, 2026
The LUT workflow keeps its single `chopper_cascade` source and publishes two outputs, a
detector table and a monitor table, as context streams. The range parameter is removed.

Two, rather than one per component, because a table is a function of `distance` and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still slightly confused: are we publishing only one table or two tables? Which one was it you were showing us screenshots of?

@SimonHeybrock SimonHeybrock Sep 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which one was it you were showing us screenshots of?

This morning? That was the released/old version which caries one big table covering the whole range (as configured). On this branch there are two tables, one for detectors (typically small range, but densely covered range), one for monitors (sparse, with some quirks around padding bins etc. that you probably saw).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table for monitors can span many meters (some monitors are close to the source, others close to the sample).
Is it really worth splitting away the table for the detectors? Can't we have one single table for everything with dense regions around the components and sparse regions in-between?

In addition, we made the lookup in numba fast by enforcing a regular grid. If I understood correctly, you find a regular chunk in the table, and then use the number interpolator on that chunk.
How fast/slow is the part that searches for the correct chunk?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good questions.

On splitting. Dense regions with nothing in between is what both tables already are — the gaps carry no rows at all, not even sparse ones. That part is forced by size: BIFROST's monitors span 0.5–155 m, which as one uniform grid at the default 0.1 m resolution is 1549 rows × 286 event_time_offset bins = 3.4 MB to carry 8 rows of information.

So the only open question is whether the detector blocks and the monitor blocks ride in one message or two. Measured from the geometry artifacts at default resolution (2.2 kB/row):

detector block monitor blocks
DREAM 77.5–79.5 m, 20 rows, 45 kB 2 monitors, 4 rows, 9 kB
LOKI 24.8–43.9 m, 191 rows, 427 kB 4 monitors, 10 rows, 22 kB
TBL 30.8–32.1 m, 13 rows, 29 kB 1 monitor, 2 rows, 4 kB
BIFROST (no placeable detector yet) 4 monitors over 0.5–155 m, 8 rows, 18 kB

Two reasons to keep them apart. The detector block is the only large one, and the one that grows with the resolution. Merging hands it to every consumer that wants a monitor: a monitor view job, and both monitor roles of an I(Q) job, each receiving it on every chopper change to read two or three rows.

The other is plotting, which is the more visible one day to day. The two tables need different plotters: the detector table is a single dense uniform block, so it renders as an image; the monitor table is a handful of rows at each monitor with tens of metres of nothing between, so its distance axis is not uniform and it is readable only as "Overlay 1D", one curve per row. Merged, no plotter setting shows both — as an image LOKI's ten monitor rows are invisible slivers on a 37 m axis, as an overlay it is 201 curves. Two outputs means picking one in the wizard and getting a readable picture; one output means building a block selector into the plotting layer to get the same thing back.

Those are the defaults, which are also essreduce's default_parameters() and what DREAM's shipped tables actually use (754 × 287 at 0.1 m / 250 µs). The argument doesn't depend on them: payload scales as 1/(distance_resolution × time_resolution), and the two axes work in our favour — the instrument with the long detector span is the one needing the least resolution (LOKI, 19 m, SANS), while the one that would want a finer grid has a 2 m span (DREAM is still 445 kB at ten times the distance resolution). Finer resolution makes merging worse, never better.

The split itself costs one output field and one stream name; the block machinery is identical either way. I've replaced the ADR's "megabyte-scale" with the measured numbers and the plotting reason.

On the chunk search. It is not in the lookup path. Block selection is a provider taking the context key, so StreamProcessor runs it once per set_context — once per arriving table, i.e. per chopper change — and caches the result for every chunk after that. What reaches WavelengthInterpolator is one uniform block, exactly as a file-based table was; the regular-grid assumption is untouched. That is precisely why select_block exists: handing it the concatenation would read wrong rows silently under numba (and correctly under the scipy fallback).

Cost: 58 µs for LOKI's detector table (one block), 112 µs for BIFROST's monitors (four), 381 µs for an invented 20-block/2000-row table. Most of that is the nanmin/nanmax over the job's Ltotal rather than the block scan — 1.1 ms if Ltotal has 1.5M pixels. For scale, essreduce builds a fresh WavelengthInterpolator on every chunk (44 µs for LOKI's block), so the once-per-table selection is worth about one chunk's interpolator construction.

The alternatives table dismissed a merged table as "megabyte-scale",
which overstates it at the default resolution: LOKI's detector block is
427 kB against 22 kB of monitor rows. State the measured sizes instead,
and add the reason the row did not give at all -- the two tables want
different plotters, since a dense uniform block renders as an image and
a few rows per monitor only as one curve per row.

Block selection sits in a context-fed provider, so it runs once per
arriving table rather than per chunk, and the interpolator still sees a
uniform grid. Say so where the block layout is documented.

@nvaytet nvaytet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any more questions.

I did not look at the tests, nor did I check all the code (it was too much to process).
I tried to think about the more general implications.

@SimonHeybrock

Copy link
Copy Markdown
Member Author

I tried to think about the more general implications.

Thanks, that was exactly what I was after.

@SimonHeybrock
SimonHeybrock merged commit ddf5498 into main Sep 8, 2026
19 checks passed
@SimonHeybrock
SimonHeybrock deleted the wavelength-lut-context-input branch September 8, 2026 05:23
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.

2 participants