Skip to content

Stage-A A1: consolidate the three A1 branches, fix the a₀ measurement, add the frequency ladder - #7

Draft
muthmann wants to merge 31 commits into
mainfrom
feature/stage-a-a1-frequency-ladder
Draft

Stage-A A1: consolidate the three A1 branches, fix the a₀ measurement, add the frequency ladder#7
muthmann wants to merge 31 commits into
mainfrom
feature/stage-a-a1-frequency-ladder

Conversation

@muthmann

@muthmann muthmann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Consolidates the three parallel Stage-A A1 branches that all forked from bb83705, fixes what reviewing them turned up, and adds the unattended frequency ladder.

Base note. This branch stacks on feature/stage-a-a1-orchestration (4046b7af22ce9a), so the diff against main includes those commits too. Added here: 361fc67, 3fee3d6, 5011dac, 7b78432, bcd0e66.

What was scattered

Three branches independently claimed ADR 012 and none contained the others:

branch content now
feature/stage-a-a1-orchestration contrast geometry, protocol-step sync, ROI-normalised rolling response base of this branch, ADR 012
feature/stage-a-a1-event-count the a₀ depth lock cherry-picked, renumbered ADR 013
fix/stage-a-a1-recording recording robustness (one folder, full duration, named failures) cherry-picked, renumbered ADR 015

That separation was not cosmetic — each branch was broken without the others.

Why the a₀ lock needed fixing first

Find a₀ closes a loop by dividing by the photodiode-measured a: a_cmd ← a_cmd · a₀/a_measured. A biased measurement is therefore not noise — it is a systematic push on the drive. Two biases were live:

  1. Wrong quantity. On its own branch, measured_log_contrast's geometry followed the photodiode display toggle. Under the default RAW mode it published the reject-port detector contrast, not the excitation contrast. Every locked depth would have been wrong, silently and consistently.

  2. Truncated window. a is peak-to-peak, but it was estimated over a fixed 16 384-sample window — 0.82 s at 20 kSa/s, less than one cycle for every f < 1.2 Hz. Below one cycle the 1st/99th-percentile extrema see an arc of the waveform, so a reads low and phase-dependent. Exactly the sub-hertz plateau reference |H(f)| = N(f)/N_plateau is normalised against — and the lock divides by it, pushing the depth up until it rails at 6.0 or the detector clips.

The contrast window is now sized from the phase-0 markers to cover whole cycles, and a is withheld below one. Retained markers cannot measure a period longer than the ring, so the interval is remembered as markers go past. window_seconds / covered_cycles join the optical summary.

Also: the per-trial dwell now covers at least one estimator window; readings are spaced by half a window instead of per service_revision; the trial value is a median with a spread gate; the clip warning threshold sits where it can actually fire; a lock-table save failure is no longer overwritten.

Why the recording fixes had to come with it

The ladder records every point through the ADR 009 coordinator. Without fix/stage-a-a1-recording, an A1 recording produced a config sidecar in the chosen folder, a truncated camera .raw in the host process's working directory, and no .pdq at all — so an unattended ladder would have written a folder of unusable points. Both commits are cherry-picked: the camera now runs its full duration and closes as a camera-only recording on a photodiode fault, the first specific cause survives to the closing message, and the PDQ is written directly into the measurement folder via the new additive PdqStartSpecV1::root_dir (the RAW is gathered after finalization, because that path rule lives in augur-rs).

The frequency ladder

Start frequency sweep runs the whole ladder on one modulation lease. Per point: retarget the frequency, wait for the phase-0 trigger to confirm the new period, run the unchanged a₀ lock, record the unchanged event-count point.

ModulationCommandV1::SetDriveFrequency is the frequency counterpart of SetOpticalDepth — additive in V1, leased only, re-derived through the owner's own builder, refused for a manual-DAC or constant drive. The owner parks the operator's armed frequency on the first retarget and restores it with the depth on release.

Both children gained an inherited-lease mode, so they run on the ladder's lease rather than taking their own. That is the substantive guarantee: the operator's drive settings are locked out from the first frequency to the last, so the amplitude provably cannot move between a lock and the point that replays it.

  • the trigger confirms the frequency, not the firmware ACK
  • retained markers and events are dropped on every frequency change, so a new frequency is never confirmed against a mixture of two drives
  • pilot windows are dropped with them — windows frozen at one period are a phase interval of that period, and carrying them across would score a point in the wrong window, silently
  • the plan is validated before the drive moves: the lowest planned frequency decides whether the photodiode can measure a at all
  • a bad point is skipped and named in the summary, not fatal; a refused frequency carries the owner's own wording

Schedule as data: log spacing, ascending/descending/alternating/seeded-random order, optional interleaved low-f reference. Every point's sidecar gains [frequency_sweep] with the executed position, order and seed.

Verification

  • cargo test --workspace — all 24 suites green; A1 49 tests, photodiode 33
  • cargo clippy --workspace --all-targets — no new warnings; cargo fmt --all clean
  • release build of all three Stage-A plugins verified

New tests pin: the sub-cycle window withheld rather than under-reported; the window growing to cover whole cycles at low f; the lock refusing a sub-cycle window up front; the lock refusing an unsettled operating point; log spacing and reproducible seeded order; the interleaved reference; the ladder locking and recording every point on one lease; an unconfirmed frequency skipped not fatal; the ladder refusing a plan its photodiode cannot measure; markers/events/pilot windows dropped on a frequency change.

Not installed to ~/.augur/plugins/ — that would put an unreviewed build on the bench. Run scripts/build-runtime-plugins.sh --profile release then scripts/install-built-plugins.sh --profile release when you want it live.

🤖 Generated with Claude Code

muthmann added 30 commits July 13, 2026 17:02
PDA1 wire protocol (fragmentation-tolerant parser with CRC resync),
v1 ASCII command grammar, typed serial client with idempotent sequence
retries and stream-integrity accounting, bounded background I/O worker,
.pdq raw-frame writer, JSON run sidecar, calibrated optical log-contrast
estimator (clipping/dark-headroom guarded), and a mock controller for
hardware-free tests. Wire-compatible with stage-a-controller
include/wire_protocol.h.
Live decimated photodiode waveform, calibrated clipping-guarded optical
contrast, stream-integrity status, and gated manual controller actions
(connect/config/start/stop/expert drive). Fails closed on the ABI v5
execution context: serial I/O only in the active live-capture worker;
commands are host actions, never persistent settings.
Statistical core: phase folding (hardware EXT_TRIGGER fiducials or
software clock-skew recovery via Rayleigh-power frequency scan),
Rayleigh detection with Bonferroni-charged trials, median-background
phase-locked excess, probit a_min fit with profile CI, hot-pixel mask
from an unmodulated reference window. Sweep engine bisects the drive
code, grids the bracketed transition, and records measured optical
contrast per point. PDQ + sidecar + results export per run; fails
closed on the ABI v5 execution context.
Feature brief for the stage-a-io/monitor/a1 stack, ADR 005 for the
device-ownership boundary (plugins own the Teensy, AugurRs stays
generic), feature index entry.

Note: the legacy plugins (localization/reconstruction/focus-metrics/
evesmlm) on this branch predate the current augur-rs plugin API and do
not compile against it — their refresh is in progress on
feature/eve-batch-findings; rebasing that work onto plugin ABI v5 only
adds the new FfiPreviewFrame.external_triggers field in one test
initializer.
… refresh

Updates the evesmlm suite, localization, and reconstruction plugins plus
repo docs to the current augur-rs plugin interface (host-view actions,
dataset display metadata/relations, TableSchema extensions, CompactEvent
accessors).
Adds the new FfiPreviewFrame.external_triggers field to the two test
initializers in focus-metrics and evesmlm-candidates; all plugins now
compile and test against the current augur-rs plugin API (ABI v5).
The mock controller spoke an invented protocol (ARM/RUN/FAULT_CLEAR verbs,
capabilities HELLO field, BAD_* error codes, arbitrary CONFIG fields), so
tests validated commands the Teensy never accepts. It now mirrors
stage-a-controller/src/main.cpp verbatim: verbs, SAFE_IDLE/CONFIGURED/RUNNING
state machine, RANGE/STATE/SYNTAX/PROTOCOL/VERB error details, single-entry
idempotent reply cache, and unknown-CONFIG-field rejection (the host's
feature-detection contract). The reserved v2 waveform fields are only
accepted behind an explicit with_waveform_extension() opt-in, which also
synthesizes photodiode blocks through a Pockels-like sin² transfer.

Host fixes uncovered by the faithful mock:
- surface async control notices (watchdog !FAULT) through poll_events even
  with no request in flight; monitor and A1 now react instead of showing a
  stale acquiring state
- A1 sweep issues STOP before CONFIG between measurement points (CONFIG is
  illegal while RUNNING)
- record the ACKed CONFIG fields in the A1 run sidecar
- reject wire frames with an unknown protocol version like the reference
  parser does
- stream the PDQ file CRC incrementally instead of buffering the whole
  recording in memory
- monitor maps the unknown_config_field rejection of drive fields to a clear
  'no waveform backend' message
New plugins/stage-a-funcgen crate: manual Pockels-cell drive control
(sine/square/sawtooth, frequency, center/amplitude DAC codes) with the
resulting optical amplitude always measured from the photodiode as
a = ln(Vmax/Vmin) — the commanded amplitude is a phase-modulation depth and
maps non-linearly to light, so it is never reported as an optical level.

The default 'mock' port runs the waveform-extended mock controller on an
in-process thread and streams a synthetic sin²-transfer photodiode response,
so the full control loop works with zero hardware. Against real firmware
0.2.0 the reserved drive fields are feature-detected via the
unknown_config_field rejection and reported as 'no waveform backend'; actual
output stays blocked on the hardware freeze per
stage-a-controller/docs/features/waveform-drive.md.

Same fail-closed safety model as stage-a-monitor: LiveCapture + effects
gating, drive parameters as settings but application as an explicit action,
local DAC-range validation before any command, watchdog fault surfacing.
…n and photodiode pair

- delete stage-a-monitor, stage-a-funcgen, stage-a-a1 (too complex for the
  current bench stage; retained in git history)
- add stage-a-modulation: capped power slider, CONST/SINE/SQUARE with
  frequency and min threshold, immediate MOD transfer, board-reported DAC code
- add stage-a-photodiode: SMA5/pin18/A4 stream readout on the second CDC
  port with RAW and EXCITATION (I_exc = I_tot - I_pd) modes and rolling chart
- extend the stage-a-io mock to firmware 0.3.0 (MOD verb, capabilities)
- ADR 006 (two plugins, one port each), rewritten stage-a brief, doc updates
The host settings UI reads enum values with as_u64() and writes the selected
variant index; the modulation/photodiode plugins returned and expected variant
name strings, so port and mode radio buttons could never be set. Map indices
against the schema's variant list in get_setting/set_setting (names still
accepted) and pin the contract with round-trip tests.
auto in stage-a-modulation now probes each attached port with HELLO and picks
the command port; auto in stage-a-photodiode listens for PD lines and picks
the stream port. Filter macOS port lists to the cu.* callout nodes so each
device appears once. Verified against the live Teensy (firmware 0.3.0).
Port entries now read '/dev/cu.usbmodem… (Teensyduino Dual Serial)' so the
Teensy is recognisable among the enumerated devices; the parenthesised label
is display-only and stripped when the setting is applied. stage-a-io gains
available_ports_with_labels() for this.
…ut camera frames

The host only calls process_frame() while camera frames flow, so the
action-button + effects-gate design never connected on a camera-less bench
(verified: the serial port stayed free while the GUI ran). Connect is now a
checkbox setting handled in set_setting, all serial I/O lives in a
plugin-owned device thread (slider drags coalesce into one pending MOD),
status comes from shared state, and replay mode still disconnects
defensively. Verified end-to-end against the live Teensy: auto-probe,
level 800 -> board code 800, level 0 -> 0.
…elope and moving average

Track firmware 0.4.0 (ADR 003 in stage-a-controller): the stream port
now carries PDA1 SamplesU16 frames at pd_stream_rate_hz instead of
50 Hz ASCII lines, raising the plot's data rate 400x.

- parse with stage-a-io's FrameParser (new dep, default-features off:
  wire parser only); auto port probe detects sample frames
- bounded raw ring (130 s / 4 M samples) keyed by device sample index;
  rate changes and index jumps restart the segment so index/rate stays
  a consistent time base across acquisition handovers
- chart decimates the window into <= 1000 min/mean/max envelope
  buckets; short windows render raw samples; window down to 10 ms
- moving-average indicator for the low-voltage regime: fixed sample
  window (default 4) or one full period of a user-set sync frequency
  (window = rate / f), making the mean phase-independent under
  modulation; overlay line + numeric readout in status views
- status table gains rate, moving avg, and stream-integrity columns
  (device drops, CRC failures, resync bytes, segment restarts)
- stage-a-io: gate the Duration import behind the hardware feature so
  default-features = false builds are warning-free

Verified: cargo fmt, clippy -D warnings (photodiode + stage-a-io),
9 plugin tests + 29 stage-a-io tests green.
…e photodiode plugin

Two save modes behind one Data settings section:

- monitor cache: the raw ring is now cache_s seconds long (default
  20 s, 1-130 s) and a Save-cache-snapshot button dumps it as
  pd_cache_<utc>.csv (sample_index, t_s on the device clock, raw code,
  raw volts) plus a JSON sidecar carrying rate, integrity counters,
  display mode, and reference so derived quantities stay reproducible
- recording: a Record toggle tees every incoming SamplesU16 frame
  verbatim to pd_rec_<utc>.pdq via stage-a-io's PdqWriter from the
  reader thread; stopping (or disabling the plugin) finalizes the file
  and writes a sidecar with per-recording integrity deltas and
  validity; the mock synthesizes identical wire frames so recordings
  parse the same without hardware
- data_dir uses the new Path setting kind; record/save failures
  surface through status entries like connect errors; status shows a
  live REC indicator with recorded seconds

Verified: cargo fmt, clippy -D warnings, 13 plugin tests green.
…odiode plugin

- PD Spectrum window: Hann-windowed radix-2 FFT (no deps) over the
  newest power-of-two window of raw samples (256-16384; ≈1.2 Hz
  resolution at 20 kSa/s), one-sided amplitude in volts with max-hold
  bin decimation so narrow peaks survive the plot budget. Window
  placement means the FFT only runs while the window is open. Verified
  by test: a synthesized 1 kHz 0.4 V tone is recovered at the right
  frequency and amplitude.
- time_axis setting: BEFORE NOW (scrolling, x ends at 0) or SEGMENT
  TIME (absolute device-clock seconds) — frozen plots and cursor
  measurements read as positions instead of implied motion.
Timed MOD sequences without cross-plugin control: the executor lives
inside the plugin that already owns the command port.

- protocol file: loops = N plus [[steps]] with duration_s, wave
  (OFF/CONST/SINE/SQUARE), level, min, frequency_hz — fully validated
  before the run starts (ranges match the firmware grammar)
- executor thread walks the steps on an absolute schedule (no drift
  accumulation) and feeds the same coalescing pending-command slot the
  device thread drains, so it never touches the serial port itself;
  the last step holds after completion (set-and-hold), stop is
  immediate, and disconnecting aborts the run
- protocol_path uses the new Path setting kind; run/stop is a
  settings-driven toggle that works with no camera; progress (loop,
  step, summary) shows in the status entries

Verified: cargo fmt, clippy -D warnings, 8 plugin tests green incl.
an end-to-end run against the mock controller.
…ry cells

Prepares the plugin for firmware 0.5.0's 500 kSa/s DMA stream (ADR 004
in stage-a-controller): a full raw-window rescan per repaint stops
being viable around that rate.

- ingest maintains 64:1 min/max/sum summary cells aligned to deque
  offsets; eviction drops whole cells so the alignment (and the
  device-clock index base) survives, at the cost of up to one cell of
  ring slack
- chart buckets and every moving-average window combine cells plus
  raw edge samples via range_summary — O(range/64) instead of
  O(range), verified exact against naive scans across cell
  boundaries and after eviction
- ring cap raised to 16 M samples (32 s at 500 kSa/s, 32 MiB of
  codes); cache_s keeps ruling the duration at lower rates

Verified: cargo fmt, clippy -D warnings, 16 plugin tests green.
The photodiode plugin dropped the pre-0.4.0 ASCII PD-line path, so a
Teensy running old firmware produced a generic 'no PDA1 sample frames'
error that gave no hint at the real cause. The auto-probe now
classifies each port (PDA1 frames / legacy ASCII / nothing) and, when
it sees the 'PD code=…' ASCII stream, tells the user to flash
stage-a-controller 0.4.0+ instead — the actual fix, since the plugin
and firmware ship in lockstep.

Verified: cargo fmt, clippy -D warnings, 16 tests green.
…ntract

Introduces `stage-a-a1` as the measurement owner that drives the modulation
and photodiode plugins through a leased device contract, plus the
`stage-a-plugin-contract` crate that carries the shared settings/telemetry
schema between them.

Also lands the supporting work these depend on:

- optical waveform generation and inversion in the modulation plugin
- Pockels transfer calibration (measured V_null / Vpi) with sweep support
- photodiode contrast estimation and monitor-cache snapshots
- `.pdq` recording format with SHA-256 integrity sidecars

Documented in docs/features/stage-a-a1.md, stage-a-a1-automation.md,
stage-a-optical-waveform.md, stage-a-pockels-calibration.md and
ADRs 007-011.
…display mode

The photodiode sits behind the PBS reject port and measures the complement
I_pd = I_tot - I_exc — a property of the bench, not of what the operator
chose to plot. `optical_summary` picked its geometry from the display mode,
so leaving the chart on RAW published the raw detector contrast as
`measured_log_contrast`. A1's amplitude sweep settles on that value against
a target `a`: it would never settle, time out at every point, and write a
wrong `measured_a` into each sweep sidecar.

The geometry is now always the rejected complement; the display mode is
presentational. A withheld `a` now reports which gate rejected the window
instead of silently showing nothing.

Also in the photodiode plugin:

- dark level is a measured setting with a capture action, applied to both
  the detector samples and the I_tot anchor so it cancels out of the
  complement instead of biasing it; `dark_id` names it honestly
- phase-0 marker frames are written into the .pdq, so a recorded run stays
  phase-attributable offline
- `save_cache_snapshot` copies the ring and releases the lock before writing
  the CSV, instead of blocking the reader across millions of writes
- the UI mirror keeps the operator's connect intent rather than clearing it
  every control tick
- a 0-byte read backs off instead of spinning a core
- the spectrum max-hold seeds each bucket with its own first bin
…ol step

`send_modulation` was silenced for an automation lease and a calibration
sweep but not for a running protocol, which queues its steps into the same
`pending` slot. Because the host re-applies the whole settings snapshot on
every sync, any settings change — from this plugin or another — dropped the
operator's armed drive on top of the protocol's queued step, and the board
held it until the next step boundary.

Also in the modulation plugin:

- `mod_freq_mhz` is parsed once as f64 and rounded to millihertz; the second
  u64 parse returned None as soon as the firmware echoed a decimal, which
  published frequency_millihz: 0 and cost A1 its fallback modulation period
- a leased `SetOpticalDepth` now parks the operator's armed depth and
  `end_lease` restores it, so the board no longer holds the last sweep
  point's depth after an A1 amplitude sweep finishes
…emoise the fold

`S_p(t)` divided by the whole sensor (`width * height`) while `q_p` on the
same screen divided by ROI area minus masked pixels, and the rolling
numerator counted events from outside the ROI and from masked pixels. With a
small ROI that under-reported `S_p` by the ROI/frame ratio, and the status
readout printed both numbers under the same "valid pixels" label.

The fold is now built from ROI-filtered events and both quantities divide by
`valid_pixel_count()`.

`current_fold()` is memoised on a fingerprint of its inputs. It is called
from `rolling_dataset`, `latest_rolling` (twice), `current_windows` and
`current_response`, each allocating a `Vec<FoldedEvent>` over up to
MAX_EVENTS — a single repaint could allocate and discard hundreds of
megabytes at bench event rates.

Also in the A1 plugin:

- the no-EventStore fallback trims by the analysis window instead of growing
  to MAX_EVENTS and then freezing on a stale buffer while the plots still
  looked live
- a failed pilot-window freeze clears the previously loaded windows, so the
  sidecar cannot record an earlier pilot's windows as this run's
The photodiode's display mode used to select the optical geometry the
published log-contrast was computed in, so a UI toggle changed a scientific
quantity that A1's amplitude sweep settles against. ADR 012 records that
geometry follows the bench, the measured dark level is applied to both sides
of the complement (where it cancels), and a withheld `a` states its reason.

Also documents the A1 `N_valid` definition (ROI area minus masked pixels, the
same denominator `q_p` uses) and generalises the rule in architecture.md: a
published field's meaning must not depend on the publisher's UI state.
…etry

Ports the per-frequency exact-event-count depth lock (ADR 013, renumbered from
012 to clear the contrast-geometry ADR) onto the branch that carries the
photodiode and modulation fixes, and repairs what that combination exposes.

The lock closes `a_cmd ← a_cmd · a₀/a_measured` against the photodiode-measured
log contrast. On its own branch that value's geometry followed the photodiode's
*display toggle*, so under the default RAW mode it was the reject-port detector
contrast rather than the excitation contrast — every locked depth would have
been wrong. It is correct only together with the fixed geometry.

Then the measurement itself:

`a = ln(I_exc,max / I_exc,min)` is peak-to-peak, but the photodiode estimated it
over a fixed 16 384-sample window — 0.82 s at 20 kSa/s, less than one cycle for
every f < 1.2 Hz. Below one cycle the robust extrema see an arc of the waveform,
so `a` comes out low and phase-dependent. That is exactly the sub-hertz plateau
reference the A1 protocol normalises |H(f)| against, and the lock *divides* by
it: a truncated estimate does not add noise, it drives the commanded depth up
trial after trial until it rails at 6.0 or the detector clips.

The contrast window is now sized from the phase-0 markers to cover
CONTRAST_WINDOW_CYCLES whole cycles, floored at the old fixed window and capped
by what the ring retains, and `a` is withheld outright below one cycle. The
retained markers cannot measure a period longer than the ring — once the ring
holds under a cycle it holds at most one marker — so the interval is remembered
as markers go past instead of recovered from what survived eviction.
`window_seconds` and `covered_cycles` join the optical summary (additive in V1).

In the lock:

- Find a₀ refuses up front when the published window is under one cycle at the
  current frequency, naming the cache length to raise. A1 always knows f, so
  this also covers an owner whose own markers cannot prove it.
- the per-trial dwell is at least one estimator window, so a trial cannot
  average the depth it just replaced; the deadline grows with it
- readings are spaced by half a window instead of per service_revision.
  Consecutive revisions share nearly their whole window, so three of them said
  no more than one
- the trial value is the median and the spread is a stability gate: readings
  straddling a₀ abort the lock instead of locking onto a drifting drive
- the clip warning threshold sits below the estimator's own refusal, where it
  can actually fire, instead of above it where it never could
- a lock-table save failure is appended to the result instead of being
  overwritten by it
An A1 event-count block is 7-12 frequencies over two or three decades, each one
a Find a₀ and a Record a₀ point, repeated over three blocks. ADR 013 left that
manual because the ordering decisions are scientific — but they are also
expressible, and the checklist wants them frozen in the session plan anyway.
Every gap between the two presses was also a gap in which a modulation settings
sync could re-apply the operator's own depth on top of the found one.

Start frequency sweep runs the whole ladder on ONE modulation lease: per point
it retargets the drive frequency, waits for the phase-0 trigger to confirm the
new period, runs the unchanged a₀ lock, and records the unchanged event-count
point. Both children gained an inherited-lease mode, so they run on the ladder's
lease instead of taking their own — which is the substantive guarantee: the
operator's drive settings are locked out from the first frequency to the last,
so the amplitude provably cannot move between a lock and the point that replays
it.

`ModulationCommandV1::SetDriveFrequency` is the frequency counterpart of
SetOpticalDepth (additive in V1, same scoping: leased only, re-derived through
the owner's own drive_command, refused for a manual DAC or constant drive). The
owner parks the operator's armed frequency on the first retarget and restores it
with the depth when the lease ends, so a finished ladder does not leave the
bench on its last point.

Robustness, which is most of the work:

- the trigger confirms the frequency, not the firmware ACK. A point starts only
  once enough phase-0 markers at the *new* period agree with the commanded one
- retained markers and events are dropped on every frequency change: the
  measured period is their mean spacing, so keeping them would confirm the new
  frequency against a mixture of the old drive and the new
- pilot windows are dropped with them. Windows frozen at one period are a phase
  interval of that period; carrying them across would score a point in the wrong
  window, silently, because a fold always produces something
- the plan is validated before the drive moves. The photodiode estimates `a`
  over one window for the whole ladder, so its lowest frequency decides whether
  the ladder is measurable at all — checked at the button press, not at the
  ninth point two hours in
- an unreachable a₀, an unconfirmed frequency or a failed recording skips that
  point and names it in the summary; the remaining decades are still recorded.
  A refused frequency carries the owner's own wording into the skip

The schedule is data: log spacing (|H(f)| is read per decade), ascending /
descending / alternating / seeded-random order, and an optional low-frequency
reference interleaved every N points. Every point's sidecar gains a
[frequency_sweep] section with the executed position, the order and the seed, so
a block is interpretable from its files rather than from a notebook.

See ADR 014.
Starting an A1 recording appeared to succeed and then reported a finished run
immediately. What landed was a config sidecar in the chosen output folder, a
truncated camera .raw in the host process's working directory, and no .pdq at
all. Three defects combined:

- Every photodiode-leg failure jumped straight to stop_camera, so the host had
  been recording for a few hundred milliseconds and produced a stub RAW that
  still carried a complete finalization receipt. Photodiode faults also set
  stop_requested, conflating them with an operator stop. The camera now runs its
  full duration and closes as a camera-only recording instead.
- The specific cause ('set the data directory first') was overwritten by the
  generic 'was incomplete' on the way out. The first cause is now preserved and
  named in the closing message.
- The RAW, the PDQ, and the sidecar are written by three owners against three
  roots, and the host's relative output path resolved to its working directory.
  Once both recorders report finalization their files are closed and hashed, so
  A1 now gathers them into <output folder>/<id>/ and records the final paths.
  PDQ receipts report a label relative to the photodiode data directory, which
  the owner now publishes in its summary so the path can be resolved.

Also pre-flights the photodiode before starting the camera (not reporting, not
connected, no data directory, leased elsewhere), surfaces the blocker in the
status view while idle, and stops A1's own pipeline restart from wiping the
row's pilot windows, background floor, and collected response points.

Cherry-picked from fix/stage-a-a1-recording onto this branch, because the
frequency ladder records every one of its points through this coordinator:
without these fixes an unattended ladder would write a folder of truncated
RAWs and no PDQ at all. Its ADR is renumbered 012 to 015 — the third branch to
have claimed 012 independently.

Refs ADR 015, revises ADR 009 decision 3.
The gather-after-finalization from the previous commit left the photodiode's
own Data directory in the critical path: an A1 run still failed when it was
unset, and changing it mid-experiment could move files out from under a
measurement.

`PdqStartSpecV1` gains an additive `root_dir: Option<String>` — an absolute
directory the client wants the recording written below, replacing the owner's
configured data directory for that run. Every safety rule the owner already had
survives below the new root (the path stays relative, `..` and non-normal
components refused, parent components must be real directories rather than
symlinks, the resolved target must stay below the root), plus the root itself
must be absolute. An A1-driven run therefore no longer depends on the
photodiode's Data directory at all, and the pre-flight stops checking it.

The camera RAW still has to be gathered after finalization: the host resolves
plugin recording paths below its own output directory and rejects absolute
ones, and that rule lives in the other repository. The gather now also runs
over the PDQ, where it is normally a no-op because the file was opened in
place — which means a run that dies before finalization still leaves its PDQ
in the measurement folder.

PDQ receipts report the label the client requested, so A1 resolves it against
the root it named, falling back to the owner's published `data_dir` and
preferring whichever exists — an owner too old to honour `root_dir` still
yields a correct path.

Cherry-picked from fix/stage-a-a1-recording; revises ADR 015 decision 4.
@muthmann muthmann changed the title Stage-A A1: fix the a₀ depth lock's measurement, then run the frequency ladder unattended Stage-A A1: consolidate the three A1 branches, fix the a₀ measurement, add the frequency ladder Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant