Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/build-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,9 @@ permissions:

env:
CARGO_TERM_COLOR: always
# The host branch this repository actually compiles against. augur-rs `main`
# does not carry the TableSchema, host-view and dataset-descriptor API these
# plugins use, so defaulting to `main` would be a guaranteed red build and
# would never hand the bench a bundle. Move this back to `main` in the same
# commit that the host API lands there.
AUGUR_RS_REF: ${{ inputs.augur_rs_ref || 'fix/gui-layout-and-alignment' }}
# A1 uses the camera-profile and confirmed-readback host commands introduced
# by the paired augur-rs PR. Move this back to `main` after that PR lands.
AUGUR_RS_REF: ${{ inputs.augur_rs_ref || 'feat/plugin-apply-biases' }}

jobs:
build:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ members = [
"stage-a-io",
"stage-a-plugin-contract",
"plugins/stage-a-a1",
"plugins/stage-a-a2",
"plugins/stage-a-a4",
"plugins/stage-a-modulation",
"plugins/stage-a-photodiode",
"plugins/localization",
Expand Down
84 changes: 84 additions & 0 deletions docs/adr/033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ADR 033 — The photodiode ring sizes itself to the drive

- Status: accepted
- Date: 2026-08-07
- Supersedes: nothing. Extends [ADR 020](./020-stage-a-a1-depth-source.md) and
[ADR 027](./027-stage-a-a1-declarative-protocols.md).

## Context

The photodiode's optical log-contrast `a` is fail-closed: it is estimated only
over a marker-bounded window covering at least **two complete modulation
cycles**, so it needs three retained phase-0 markers. The window can never be
longer than the raw ring, and the ring was sized by one operator setting —
**Cache length**, 20 s by default, 130 s maximum, hard-capped at 16 M samples
(32 s at the bench's 500 kSa/s).

Two cycles at the A1 laboratory protocols' 0.075 Hz floor are 26.7 s. The
default retains 20 s. So every sub-hertz rung of those files was structurally
incapable of producing an `a` — and the cost was paid at the worst possible
moment:

- A1 pre-checks the photodiode before it starts a recording, but right after a
retarget the ring still holds markers from the *previous, faster* rung. The
check passed on those.
- The old markers then aged out during the recording, and the refusal arrived at
`write_sidecar`, i.e. after the point had run its full 120–267 s. A1 counts
such a point as skipped, so the run kept its RAW and PDQ files and lost the
metadata that makes them quantitative.

A bench session on 2026-08-07 reported `point 4/49 — 1 recorded, 2 skipped`
against exactly this. The recorded mitigation was documentation: "set and verify
the photodiode cache at 30 s before starting this file", asserted by a test that
pinned the 30 s setting. That is a precondition no software checks, that has to
be recomputed per file from its lowest frequency, and whose omission is only
discovered a recording at a time.

## Decision

The ring is sized by the drive, not only by the setting:

```
capacity = clamp(max(cache_seconds × rate, (CONTRAST_WINDOW_CYCLES + 1) × period),
2, RING_MAX_SAMPLES)
```

where `period` is the marker-measured modulation period in samples. The
operator's **Cache length** becomes a floor rather than the whole answer.

- The period comes from the **newest** marker interval, falling back to the mean
over retained markers. The newest interval moves to the new period on the
first marker after a retarget, where the mean still carries the previous rung
and would grow the ring one cycle at a time. It also survives eviction, so a
period longer than the ring itself — the case this exists for — is still known.
- One cycle beyond the estimator's window, so a whole window still fits once the
oldest marker ages out of it.
- Sizing follows the drive **both** ways: eviction re-reads the capacity every
ingest, so the ring shrinks again when the frequency goes back up.
- `RING_MAX_SAMPLES` still binds. Below ~0.06 Hz at 500 kSa/s nothing retains two
cycles and the estimator refuses — correctly, and now for a reason no setting
can talk it out of.

Independently, A1's sidecar refusal quotes the owner's published
`optical_unavailable` reason instead of naming the `I_tot` anchor whatever the
real gate was. That refusal is the entire report an unattended protocol run
leaves behind for a point it lost.

## Consequences

- A sub-hertz A1 protocol runs with no cache preconditions. The
"verified 30 s cache" step is removed from the feature brief, the plugin
README and the shipped protocol headers.
- Worst case memory is unchanged: `RING_MAX_SAMPLES` was already the documented
ceiling, the ring just reaches it on its own at low `f` (32 MiB of codes plus
~2 MiB of summary cells).
- A bogus period estimate — one dropped marker doubles the interval — grows the
ring toward that same ceiling and self-corrects on the next marker.
- A cache set shorter than the drive is no longer a way to starve the estimator,
so the unit test that produced `IncompleteModulationCycles` that way now
produces it the way the bench does: a drive whose cycles have not gone by yet
(the first marker after a retarget or a segment restart).
- Still not fixed by this ADR: the pre-recording check can pass on a summary
built from the previous rung's markers. It is now only a decision about
whether to *start*, because the window at the end of a recording is what the
sidecar records, and every shipped protocol row runs at least two cycles.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR 034 — The A1 sidecar records the recording's own light

- Status: accepted
- Date: 2026-08-07
- Related: [ADR 033](./033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md),
[ADR 017](./017-stage-a-rail-detection-and-withheld-a-reasons.md),
[ADR 015](./015-stage-a-a1-recording-robustness.md)

## Context

A1 refuses to write a quantitative sidecar without a photodiode optical summary,
and the summary it used was read **live, at the moment the metadata was written**
— gated on the owner's `FreshnessV1`, a 2 s budget.

That moment is not adjacent to the recording. Between the last sample and
`write_sidecar` sit the photodiode finalize, the camera finalize, and
`gather_into_measurement_folder`, which moves the RAW, its bias sidecar and the
PDQ into the measurement folder — a `rename` within a volume, but a full **copy**
across one. All of it runs inside A1's own control tick, so no photodiode
snapshot can arrive while it happens. The freshness budget then expires against
wall-clock time the recording spent being written out, and the sidecar is
refused for a recording that is otherwise complete and correct.

The failure scales with the recording: the larger the RAW, the longer the
gather, the more certain the refusal. A run of
`a1_direct_sensor_647_gate.csv` on 2026-08-07 skipped its two 100 s rows and
recorded the 20 s row that followed them.

The refusal itself then named the `I_tot` anchor whatever the real gate had been,
so the operator was sent to re-confirm an anchor that was fine.

## Decision

**The sidecar's optical section is latched while the recording runs.** Every
control tick with an active recording copies the newest fresh
`PhotodiodeOpticalSummaryV1` into the recording state; `write_sidecar` reads that
latch, and only falls back to a live read for a sidecar written outside a
recording.

This is not only a robustness fix. The sidecar's job is to describe the light
**the recording was made under** — a summary observed after both finalizes is the
wrong number to record even when it is available. `depth_a` for a
photodiode-sourced run comes from the same latched window, so the recorded depth
and the optical section can never disagree.

**The refusal quotes the owner.** When there is no summary at all, the error
carries the photodiode's published `optical_unavailable` reason — the only side
that knows which estimator gate closed. A1's existing
`photodiode_a_blocker` gains a sibling that omits the "switch Depth `a` source"
escape, because the sidecar needs this summary whichever depth source is
selected: offering the escape there would name a way out that does not exist.

## Consequences

- A recording is no longer lost for having been large, and the sidecar carries
the conditions of its own recording rather than of its file moves.
- A protocol point that is skipped now reports the gate that skipped it. For an
unattended survey, that one sentence is the entire report.
- The latch holds the last summary seen *during* the recording, which for a long
row is up to one control tick before the last sample — not the mean over the
recording. The PDQ carries the full stream for anyone who needs more.
- Unchanged: a recording that never saw a fresh summary at all is still refused.
Fail-closed was never the defect.
110 changes: 110 additions & 0 deletions docs/adr/035-stage-a-a4-threshold-survey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ADR 035: A Threshold Point Is Only Real If The Sensor Confirms It

## Status

Accepted (2026-08-08), implemented in `plugins/stage-a-a4`.

## Context

Stage-A A4 measures the IMX636's contrast threshold: hold the optical condition
still, step `diff_on`/`diff_off` through a list, record a RAW file at each, and
read the event rate against the threshold setting afterwards. It is the one
Stage-A measurement whose independent variable is a **camera bias**.

Three things make that harder than "set a slider and press record".

1. **The requested value is not the measured one.** The settings panel shows an
*offset* around a per-unit factory trim. The quantity the physics depends on
is the absolute 8-bit code in the bias register. They differ by a trim that
varies between sensors, and the offset is clamped into the register on the
way in.
2. **Nothing else may move.** `fo`, `hpf`, `refr`, the ROI and the pixel mask
all change the event rate. So do the STC and Trail filters, which discard
events *before* they are streamed — the quantity being counted.
3. **The bench drifts.** A survey runs for hours. Die temperature and
illumination move under it, and whether that invalidated a given point is
not something the runner can decide.

A4 also could not exist at all until the plugin interface could change a bias.
That half was originally augur-rs ADR 036, a verb written for A4 and two fields
wide, so point 2 above was enforced by the wire. augur-rs ADR 037 replaced it
with a generic camera-configuration session, on the grounds that the host must
carry no plugin- or experiment-specific command. The decision below is
unchanged by that; what changed is where point 2 is enforced. A4 now opens a
run by having the host confirm the configuration the bench is on, and builds
every point by cloning that snapshot and setting only `diff_on` and `diff_off`.
A test asserts the equality field by field.

## Decision

**Every point is confirmed against the sensor's own readback before it is
recorded.** A4 sends the two offsets, then checks that the absolute codes the
sensor reports are `factory_default + offset`, and that the reading confirming
them is fresh. A point whose codes disagree, or whose confirming reading is
missing or stale, is **skipped** — it would not be measuring what the protocol
says it measures, and recording it anyway produces a file that is wrong in a
way nobody can detect later. Every sidecar carries the confirmed absolute
codes, the factory trim, and the age of the reading.

Consequently a survey **refuses to start without a bias readback at all**.
Without one the method's central claim is uncheckable, and a run that cannot be
checked should not pretend to have run.

**The freeze on everything else is structural.** A4 uses a host command that
has no field for `fo`, `hpf`, `refr`, the ROI or the mask, so it cannot disturb
them even by mistake. That is stronger than a rule the plugin has to follow.
The filters are a hard refusal, checked both by A4 before the run and by the
host on every command.

**A settle is not over until the sensor has been read again.** Waiting out
`settle_s` proves only that time passed. Requiring a monitoring sample newer
than the settle is what makes the point's recorded start conditions belong to
the point rather than to the state before the bias change.

**Bench-stability limits are flags, not gates.** `max_temperature_drift_c`,
`max_illumination_drift_percent` and `max_event_rate` mark a point and are
carried into its sidecar and the run summary; the recording is kept and the
survey continues. Whether a 2 °C drift invalidated a threshold point is a
judgement to make later with the file in hand, and a runner that discarded the
point would have destroyed the evidence for making it.

A limit whose quantity could **not be measured** is flagged rather than passed.
Otherwise a camera with no temperature readback silently reports every point as
within a drift limit nobody ever checked — the worst of the three outcomes,
because it looks like a verified result.

**File completeness is a gate.** Size, hash, duration and a clean finalize are
all checked. A `RecordingPartial`, an empty file, a missing hash, or a
recording materially shorter than requested is never counted as recorded,
whatever the host called the outcome. The file is kept and the sidecar says
why.

**The bench is put back.** The offsets the survey found are captured before
anything moves and re-applied on completion, on Stop, and on any abort. The run
does not close until that restore is answered, so a survey never disappears
while the sensor is still on its last threshold. They are also remembered after
the run for a manual `Restore biases`, which is the recovery path for a run
that could not restore them itself.

**Failed points get sidecars too.** The record of a failed point is the reason
the survey has a hole in it.

## Consequences

An overnight threshold survey is one button press, and every point on disk can
prove which codes were live on the die while it was written.

The cost is that a bench without a monitoring block cannot run A4 at all —
deliberately, since on such a bench the measurement would be unverifiable. A
survey on a drifting bench still completes, and the drift is visible per point
rather than being resolved by the runner.

## References

- augur-rs ADR 037: host-owned camera profiles and generic plugin configuration
sessions (supersedes the A4-specific `apply_biases` verb of augur-rs ADR 036)
- ADR 022: Stage-A A1 sensor conditions on every run (absent, never `0`)
- ADR 027: Stage-A A1 declarative protocols (the protocol shape A4 follows)
- ADR 028: the sensor readout travels with the measurement, column-wise
- ADR 031: shared code crosses plugin boundaries through a vtable-free crate
- `docs/features/stage-a-a4.md`
54 changes: 54 additions & 0 deletions docs/adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR 036 — Stage-A drive bounds and A1 measurement bounds are separate

- **Status:** Accepted
- **Date:** 2026-08-12
- **Relates to:** `stage-a-controller` ADR 004, Stage-A modulation, Stage-A
photodiode, Stage-A A1

## Context

The Rust plugins repeated a 2 kHz literal in settings, service validation, and
protocol parsing. Raising one copy would make the UI promise a frequency that
another layer refused. It would also confuse two different limits: generating a
periodic drive and resolving that waveform with the photodiode.

The firmware is present in the sibling `stage-a-controller` repository. Its
`board_config.h` fixes the MOD range at 0.01 Hz to 2 kHz and the sine DAC update
ceiling at 40 kHz. At the maximum frequency the waveform has 20 DAC updates per
cycle. No local scope qualification supports a higher drive limit.

Firmware 0.5.0 separately streams the photodiode at 500 kSa/s by default, with a
1 MSa/s configured ceiling. This DMA path still has pending cadence, ENOB, and
analog-front-end bench acceptance. Older command acquisitions and mock data can
report 20 kSa/s.

## Decision

`stage-a-plugin-contract` owns the firmware-qualified Rust constants:

- `DRIVE_FREQUENCY_MIN_MILLIHZ = 10`;
- `DRIVE_FREQUENCY_MAX_MILLIHZ = 2_000_000`;
- `DRIVE_DAC_UPDATE_RATE_HZ = 40_000`.

The modulation settings, setting setter, apply path, service validation, A1
protocol validation, error text, and tests use these constants. The software
maximum remains **2 kHz**. A higher value needs a new firmware waveform design
and scope validation first.

A1 has an additional measurement gate. It reads the current photodiode sample
rate from the owner's fresh status and requires at least 16 samples per cycle.
The accepted A1 limit is therefore `sample_rate_hz / 16`: 1.25 kHz at 20 kSa/s
or 31.25 kHz at 500 kSa/s. This is stricter than Nyquist because A1 measures
waveform extrema and phase, not only signal presence. The drive limit still
wins at 2 kHz on current firmware.

Missing or stale sample-rate status refuses the recording. There is no silent
clamp and no artefact labelled with a frequency that was not applied or could
not be measured under the declared sampling rule.

## Consequences

Some current 20 kSa/s acquisition modes can output 2 kHz but A1 refuses to
record it above 1.25 kHz. The 500 kSa/s stream has enough digital sample density
for the full 2 kHz drive range, subject to the firmware ADR 004 bench acceptance
and the analog photodiode bandwidth.
Loading
Loading