From 0f0bdbbd20c1ba5744767085af192ad31125f442 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Wed, 12 Aug 2026 16:23:29 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(stage-a-a1):=20=E2=9C=A8=20apply=20c?= =?UTF-8?q?onfirmed=20camera=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-plugins.yml | 9 +- ...otodiode-ring-sizes-itself-to-the-drive.md | 84 ++ ...idecar-records-the-recordings-own-light.md | 63 + ...a-frequency-bounds-and-a1-sampling-gate.md | 54 + ...1-camera-configurations-and-bias-points.md | 48 + docs/architecture.md | 3 + docs/features/stage-a-a1.md | 123 +- docs/features/stage-a-photodiode.md | 19 +- plugins/stage-a-a1/README.md | 44 +- plugins/stage-a-a1/plugin.toml | 8 +- plugins/stage-a-a1/src/lib.rs | 7 +- plugins/stage-a-a1/src/protocol.rs | 257 +++- plugins/stage-a-a1/src/runtime.rs | 1146 ++++++++++++++++- plugins/stage-a-modulation/src/lib.rs | 31 +- plugins/stage-a-photodiode/src/lib.rs | 116 +- .../src/csv.rs | 0 stage-a-plugin-contract/src/lib.rs | 45 +- .../src/telemetry.rs | 29 +- 18 files changed, 1974 insertions(+), 112 deletions(-) create mode 100644 docs/adr/033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md create mode 100644 docs/adr/034-stage-a-a1-sidecar-records-the-recordings-own-light.md create mode 100644 docs/adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md create mode 100644 docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md rename {plugins/stage-a-a1 => stage-a-plugin-contract}/src/csv.rs (100%) rename plugins/stage-a-a1/src/sensor.rs => stage-a-plugin-contract/src/telemetry.rs (92%) diff --git a/.github/workflows/build-plugins.yml b/.github/workflows/build-plugins.yml index 7f8278b..33ac0ae 100644 --- a/.github/workflows/build-plugins.yml +++ b/.github/workflows/build-plugins.yml @@ -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: diff --git a/docs/adr/033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md b/docs/adr/033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md new file mode 100644 index 0000000..fa81856 --- /dev/null +++ b/docs/adr/033-stage-a-photodiode-ring-sizes-itself-to-the-drive.md @@ -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. diff --git a/docs/adr/034-stage-a-a1-sidecar-records-the-recordings-own-light.md b/docs/adr/034-stage-a-a1-sidecar-records-the-recordings-own-light.md new file mode 100644 index 0000000..ba0b8ae --- /dev/null +++ b/docs/adr/034-stage-a-a1-sidecar-records-the-recordings-own-light.md @@ -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. diff --git a/docs/adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md b/docs/adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md new file mode 100644 index 0000000..b8340fc --- /dev/null +++ b/docs/adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md @@ -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. diff --git a/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md new file mode 100644 index 0000000..6ba5e64 --- /dev/null +++ b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md @@ -0,0 +1,48 @@ +# ADR 037 — A1 protocols apply host camera configurations and point biases + +- **Status:** Accepted +- **Date:** 2026-08-12 +- **Relates to:** ADR 027, ADR 035, `augur-rs` ADR 036 and ADR 037 + +## Context + +An A1 series can depend on the camera configuration and on different contrast +thresholds per point. Requiring an operator to move settings and click Apply +between rows is not reproducible. A1 must not open camera hardware directly or +invent plugin-local copies of host-owned global settings. + +## Decision + +An A1 protocol can select one complete camera configuration for the series: + +- TOML uses `[camera] profile = "name"` or an inline `snapshot`, exactly one; +- CSV uses one consistent `camera_profile` value for the series. + +Per-point threshold offsets use only the host's canonical names `diff_on` and +`diff_off`. TOML supports defaults and block overrides; CSV supports the two +columns per row. The values are relative offsets around the sensor's factory +trim. No `bias_on` or `bias_off` aliases are introduced. + +A1 routes full configuration selection through +`ApplyCameraConfiguration` and point changes through the existing narrow +`ApplyBiases` command. The host applies them immediately. A1 waits for the host +reply containing a sensor read taken after the change. It never waits for an +extra user Apply action and never records an unconfirmed point. + +Bias control requires a fresh sensor-monitoring context before the drive moves. +Missing or disabled sensor reading, an out-of-range offset, a rejected apply, +or a mismatched/missing readback fails closed. Drive retarget replies and the +bias confirmation must both arrive before settle and recording. + +The host-start metadata and A1 sidecar store requested offsets, confirmed +offsets, absolute current and factory codes, readback age, the immutable camera +snapshot, and profile provenance. The host restores a full configuration +session; a bias-only protocol restores the offsets measured before the run. +Normal completion, Stop, and abort use the same restore path and do not report +success until the restore reply arrives. + +## Compatibility + +Existing TOML and CSV protocols have no camera selection and no bias columns, +so their parsed points and runtime path are unchanged. Unknown future snapshot +schemas and invalid profiles are rejected by the host. diff --git a/docs/architecture.md b/docs/architecture.md index a46c4c1..0925914 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,9 @@ Key properties: - `sensor_height` - `acq_time_ms` - `event_store_budget_bytes` +- `record_sensor_telemetry` +- active ROI and masked pixels +- event-filter state New plugins should prefer this shared host contract over duplicating pixel scale or sensor geometry in plugin-local defaults. diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index fe7d18c..dc3983a 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -31,7 +31,16 @@ (the sensor readout travels with the measurement, column-wise), [ADR 029](../adr/029-stage-a-leases-are-renewed-against-the-granted-deadline.md) (a leased run heartbeats against the deadline the owner granted, so a point - longer than the owner's TTL cap no longer loses the drive mid-recording) + longer than the owner's TTL cap no longer loses the drive mid-recording), + [ADR 034](../adr/034-stage-a-a1-sidecar-records-the-recordings-own-light.md) + (the sidecar's optical section is latched during the recording, so a large + recording no longer loses its metadata to the time its own files took to + write, and a refusal quotes the gate that caused it), + [ADR 036](../adr/036-stage-a-frequency-bounds-and-a1-sampling-gate.md) + (firmware-qualified drive limits remain separate from A1 sample-density), + [ADR 037](../adr/037-stage-a-a1-camera-configurations-and-bias-points.md) + (protocols apply host camera profiles and per-point biases with readback and + restore). - **Automation roadmap:** [Stage-A A1 Automation](./stage-a-a1-automation.md) - **Second workflow:** [Stage-A A1 Exact Event Count](./stage-a-a1-event-count.md) — hold one *measured* depth `a₀` across the frequency sweep @@ -48,8 +57,9 @@ A1 has two jobs on the Stage-A bench, both deliberately thin: 2. **Live sanity quicklooks.** The rolling half-period response `S_p(t)` and the response probability `q_p`, folded on the modulation period `T`. -A1 owns no hardware and never drives the Teensy. The optical drive is armed in the -modulation plugin; A1 only *reads* its published settings. +A1 owns no hardware and never opens the Teensy or camera directly. The optical +drive remains owned by the modulation plugin and camera settings remain owned +by the host; A1 can retarget them only through their allowlisted control paths. ## The recording workflow @@ -218,16 +228,18 @@ spreadsheet, comes straight out of a script, and each row carries its own duration. ```csv -label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role -floor,0.50,10,0.02,20,3,background -windows,0.50,10,2.00,20,3,pilot -ladder,0.40,1,0.80,40,4, -ladder,0.40,200,0.80,10,2, +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role,diff_on,diff_off +A1_low_noise,floor,0.50,10,0.02,20,3,background,12,-7 +A1_low_noise,windows,0.50,10,2.00,20,3,pilot,12,-7 +A1_low_noise,ladder,0.40,200,0.80,10,2,,20,-8 ``` Required: `mean_u`, `frequency_hz`, `depth_a`. Optional: `duration_s` (default 10), `settle_s` (default 2), `role` (`normal`/`pilot`/`background`), -`label`. Columns are located by header name, `#` comments and blank lines are +`label`, `camera_profile`, `diff_on`, and `diff_off`. One CSV series may name +only one profile. The two bias columns are relative factory-trim offsets and +are applied by A1 through the host; there are no `bias_on`/`bias_off` aliases. +Columns are located by header name, `#` comments and blank lines are skipped, a blank cell falls back to the default, and an error names the file line number. @@ -242,9 +254,14 @@ Two capabilities follow from the row form: **TOML — blocks and ranges**, kept for a dense regular sweep: ```toml +[camera] +profile = "A1_low_noise" + [defaults] duration_s = 10 settle_s = 2.0 +diff_on = 12 +diff_off = -7 [[block]] name = "frequency-ladder" @@ -252,11 +269,34 @@ mean_u = [0.3, 0.6] frequency_hz = { min = 1.0, max = 200.0, points = 6, spacing = "log" } depth_a = 0.8 duration_s = 20 +diff_on = 20 ``` Each axis takes a single value, an explicit list, or a `{ min, max, points }` range with `linear` (default) or `log` spacing; a block records the product of -its three. +its three. Instead of `profile`, `[camera]` may contain one complete versioned +`snapshot`. A profile is resolved once by the host and the immutable resolved +snapshot, profile revision, and hash travel with every recording. + +Camera values are applied immediately through the host camera-control path; the +operator does not click Apply again. A point waits for a fresh sensor readback +that confirms the requested codes. Sensor reading missing or disabled refuses +the run. Completion, Stop, and abort restore the settings that were active +before the protocol. + +### Frequency generation and measurement limit (ADR 036) + +Current firmware can generate 0.01 Hz to **2 kHz**. Its sine DAC tick is capped +at 40 kHz, which leaves 20 updates per cycle at 2 kHz. The Rust UI, service, A1 +protocol parser, and errors share this firmware-qualified bound; values are +never silently clamped across a service request. + +A1 separately requires at least 16 photodiode samples per cycle. It uses the +fresh sample rate reported by the photodiode owner. At 20 kSa/s the scientific +measurement limit is 1.25 kHz; at the firmware 0.5.0 default 500 kSa/s it is +31.25 kHz, above the current 2 kHz generation ceiling. The 500 kSa/s DMA path, +ADC ENOB, and analog-front-end bandwidth still need the firmware ADR 004 bench +acceptance before high-frequency data is treated as qualified. - **`mean_u` is the `I_k` axis** — the normalized cycle-mean lobe point, driven by the new `ModulationCommandV1::SetOperatingPoint`. Dimensionless, not @@ -278,6 +318,42 @@ its three. the reasons are kept on the run and shown in the status pane and the closing summary. +### Qualified laboratory protocols + +The current A1 laboratory set is versioned beside the examples: + +- `a1_stufe1_bode_dc.csv` — 73 recordings; +- `a1_stufe2_bode_u010.csv` — 47 recordings; +- `a1_stufe2_bode_u045.csv` — 47 recordings; and +- `a1_stufe2_flussleiter.csv` — 231 recordings. + +Their integration tests parse the shipped CSV with A1's production reader, +quantize every coordinate as the service does, and replay the runtime command +order `SetOperatingPoint` → `SetDriveFrequency` → `SetOpticalDepth`. Every +intermediate state is checked with the modulation owner's `PeakLaw`, recorded +2026-07-30 Pockels lobe, Bessel-normalized log-sine pedestal, inverse warp table +and DAC ceiling. The files additionally keep their conservative protocol policy +`u_peak <= 0.90`. When the sibling `Playground/protocols` directory is present, +the test requires its bench copies to be byte-for-byte identical to the shipped +fixtures. + +The photodiode integration test uses the production ring-capacity calculation. +With the cache length left at its default, the ring sizes itself to the marker +period and covers two complete cycles at the files' 0.075 Hz floor (ADR 033); +every individual recording is also required to span at least two cycles. The +same test keeps the witness that the 20 s default is far too short on its own — +that gap used to be an operator precondition, and a survey failed on it one +full-length recording at a time. + +Passing these tests qualifies the declared schedule, not the live apparatus. +Before starting one of these files, arm the calibrated optical-log-sine drive +with `a <= 1.70`, select the 2026-07-30-equivalent valid lobe and DAC ceiling, +and complete the protocol header's anchor, connection, lease, disk-space and +laser/HV checks. There is no cache length to set. In particular, the +initial `a <= 1.70` is required because A1 changes `mean_u` before it changes +`depth_a`; the first operating-point request is therefore validated against the +operator-armed depth left in the modulation owner. + One lease covers the whole file. `plugins/stage-a-a1/protocols/example.toml` is a commented file to copy. @@ -294,6 +370,7 @@ records what the camera measures about itself, from the host's | scene illumination, lux | `illumination_lux` | `sensor_illumination_lux` | | staleness of the reading, s | `reading_age_s` | `sensor_reading_age_s` | | absolute bias codes | `bias_diff_on/_off/_fo/_hpf/_refr` | — | +| factory bias codes | `factory_diff_on/_off/_fo/_hpf/_refr` | — | All three bear directly on `q_p(a, f)`: the dead time caps events per pixel per half-cycle, the lux *is* the physical `I_k` axis, and temperature moves the @@ -304,6 +381,13 @@ with an offline re-run of the same data. A quantity the sensor cannot report is **omitted**, never written as `0`; replay and cameras without a monitoring block produce no `[sensor]` section at all. +For a camera-controlled protocol, `[camera_control]` additionally stores the +resolved versioned snapshot and profile provenance, the point's requested +`diff_on`/`diff_off`, confirmed offsets, absolute readback, readback age, and +`status = "confirmed"`. The same profile name/revision/hash and point values are +sent in recorder metadata, so the RAW, PDQ, and A1 sidecar identify one immutable +configuration even if the saved profile later changes. + For manual recordings A1 never drives the Teensy: set the drive (high `a` for the pilot, `a≈0` for the background) in the modulation plugin, then press the matching button — the recording captures whatever `a` is currently set. @@ -383,7 +467,18 @@ the photodiode Data directory no longer have to be kept aligned by hand: single-point die temperature / dead time / illumination in `[sensor]` come from the context bus and are recorded with every run regardless (ADR 022). -**A1 config sidecar** captures: `measurement_id`, file +**A1 config sidecar** captures the light **the recording was made under**: the +optical section is latched from the newest fresh photodiode summary seen while +the recording ran, not read live when the metadata is written (ADR 034). The +finalizes and the gather between the last sample and that write block A1's own +control tick, so a live read is judged against a 2 s freshness budget that has +been expiring on the recording's own write-out time — the larger the RAW, the +more certain the refusal. `depth_a` for a photodiode-sourced run comes from the +same latched window, so the recorded depth and the optical section cannot +disagree. When there is no summary at all the refusal now quotes the owner's +published reason instead of naming the `I_tot` anchor whatever the gate was. + +It captures: `measurement_id`, file stem, role, start/finalize timestamps, duration; the sweep `[min_a, max_a]`; modulation settings from the acknowledged snapshot (frequency, center/amplitude DAC, waveform, transfer @@ -549,6 +644,12 @@ finalize lifecycle (including envelope identity/revision and save location), the selective discontinuity reset, and the `a₀`-lock and frequency-ladder sets listed in the [exact-event-count brief](./stage-a-a1-event-count.md). +The qualified laboratory CSVs are covered across the owning crates, not by a +standalone copy of their formulas. Run +`cargo test -p augur-plugin-stage-a-a1 -p augur-plugin-stage-a-modulation -p augur-plugin-stage-a-photodiode`: +A1 owns parsing and service-order behavior, modulation owns the coupled optical +acceptance calculation, and photodiode owns the retained-window capacity. + Three of them guard the recording defects fixed in ADR 015: a photodiode leg that cannot start is refused before any host command is sent; a photodiode failure mid-run keeps the camera recording for the full duration, names the cause in the diff --git a/docs/features/stage-a-photodiode.md b/docs/features/stage-a-photodiode.md index 70d51a5..416c969 100644 --- a/docs/features/stage-a-photodiode.md +++ b/docs/features/stage-a-photodiode.md @@ -1,7 +1,7 @@ # Stage-A Photodiode - **Crate:** `plugins/stage-a-photodiode` (`augur-plugin-stage-a-photodiode`) -- **Firmware:** `stage-a-controller` 0.4.0+ (`PDSTREAM_PDA1`), Teensy **stream port** (second CDC port) +- **Firmware:** `stage-a-controller` 0.5.0+ (`PDSTREAM_PDA1`), Teensy **stream port** (second CDC port) - **Status:** Active (2026-07-16) — replaces the readout half of `stage-a-monitor` - **Design:** [ADR 006](../adr/006-stage-a-two-plugin-split.md) (the split), [ADR 012](../adr/012-stage-a-contrast-geometry-is-bench-not-display.md) (the @@ -15,10 +15,11 @@ ## What it is -A live readout of the photodiode on **board SMA5 → Teensy pin 18 / A4**. Firmware 0.4.0 streams -PDA1 `SamplesU16` frames free-running at `pd_stream_rate_hz` (20 kSa/s default) on its second USB +A live readout of the photodiode on **board SMA5 → Teensy pin 18 / A4**. Firmware 0.5.0 streams +PDA1 `SamplesU16` frames free-running at `pd_stream_rate_hz` (500 kSa/s default) on its second USB serial port; a background thread parses them with `stage-a-io`'s `FrameParser` into a bounded raw -ring (up to 130 s / 4 M samples), and the plugin renders a rolling chart (10 ms – 120 s window) +ring (**Cache length** 1–130 s, and never more than 16 M samples — 32 s at the bench's 500 kSa/s), +and the plugin renders a rolling chart (10 ms – 120 s window) plus the newest value. During a command-port acquisition the firmware mirrors the acquisition blocks here — every rate change or sample-index jump restarts the ring as a new segment, so the `index / rate` time base is always consistent. @@ -75,6 +76,16 @@ amplitude sweep settles on this value, so a display toggle must not be able to m complete modulation cycles**, ending on phase 0. It no longer estimates extrema from an arbitrary trailing sample count; a low-frequency trace that does not fit the bounded window is withheld rather than phase biased. +- **The ring sizes itself to the drive** (ADR 033). Because that window is the + gate, the retained ring is the larger of the operator's **Cache length** and + nine marker-measured periods, capped at 16 M samples. Two cycles at the A1 + protocols' 0.075 Hz floor are 26.7 s, which no default cache covers — left to + a setting, "raise the cache before starting a sub-hertz file" is a + precondition nothing checks and a whole survey fails on, one full-length + recording at a time. Two phase-0 markers are enough to know the period, the + ring shrinks back when the frequency goes up, and below ~0.06 Hz at 500 kSa/s + the cap binds and the estimator refuses — correctly, since nothing retains + two cycles there. - The estimator is **fail-closed**: it refuses when no anchor has been observed yet, on incomplete cycles, on ADC clipping, and when the excitation never dims below the brightest the detector has been — where there is no complement left diff --git a/plugins/stage-a-a1/README.md b/plugins/stage-a-a1/README.md index 048df5e..aaeb5da 100644 --- a/plugins/stage-a-a1/README.md +++ b/plugins/stage-a-a1/README.md @@ -150,11 +150,9 @@ is chosen by extension. ### CSV — one row per recording (the one to reach for) ```csv -label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role -floor,0.50,10,0.02,20,3,background -windows,0.50,10,2.00,20,3,pilot -ladder,0.40,1,0.80,40,4, -ladder,0.40,200,0.80,10,2, +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role,diff_on,diff_off +A1_low_noise,floor,0.50,10,0.02,20,3,background,12,-7 +A1_low_noise,ladder,0.40,200,0.80,10,2,,20,-8 ``` | column | | | @@ -166,6 +164,8 @@ ladder,0.40,200,0.80,10,2, | `settle_s` | optional, default 2 | dwell after retargeting, 0–60 | | `role` | optional, default `normal` | `normal`, `pilot` or `background` | | `label` | optional | free text for the status line and sidecar; quote it if it contains a comma | +| `camera_profile` | optional | one host-owned named camera/global profile for the full series | +| `diff_on`, `diff_off` | optional | per-point factory-relative threshold offsets; A1 applies and confirms them through the host | Columns are found **by name**, so their order does not matter and one can be left out entirely. Blank lines and `#` comments are skipped, and a blank cell falls back to the default. Errors carry @@ -184,9 +184,14 @@ a complete measurement, not one that needs two button presses first. Kept for a dense regular sweep, which a 96-row CSV states badly: ```toml +[camera] +profile = "A1_low_noise" + [defaults] duration_s = 10 settle_s = 2.0 +diff_on = 12 +diff_off = -7 [[block]] name = "frequency-ladder" @@ -199,6 +204,18 @@ duration_s = 20 Each axis takes a single value, a list, or a `{ min, max, points }` range (`linear` default, `log` for per-decade ladders); a block records the product of its three, `ū` outermost then `f` then `a`, which settles the slow axis least often. `duration_s`/`settle_s` are per block. +`diff_on`/`diff_off` may be defaults or block overrides. `[camera]` may select +one named profile or one complete versioned inline snapshot. + +Plugin camera changes are applied immediately by the host and shown as applied +settings; no extra user Apply click is required. A1 records only after a fresh +sensor readback confirms the codes and restores the pre-run settings on success, +Stop, or abort. Missing/disabled Sensor reading fails closed. + +The current firmware-qualified drive range is 0.01 Hz to 2 kHz. A1 also +requires at least 16 photodiode samples per cycle: 1.25 kHz at 20 kSa/s and +31.25 kHz at 500 kSa/s. The lower of this measurement bound and the 2 kHz drive +bound applies. ### Either way @@ -211,6 +228,23 @@ its wording, and the reasons are kept on the status pane and in the closing summ is validated on the button press, before the drive moves, and the point count and expected bench time are reported first. Use **Stop** in the Record section to end a run early. +### Qualified A1 bench files + +The installed protocol directory also contains the four validated laboratory schedules: + +- `a1_stufe1_bode_dc.csv` (73 recordings) +- `a1_stufe2_bode_u010.csv` (47 recordings) +- `a1_stufe2_bode_u045.csv` (47 recordings) +- `a1_stufe2_flussleiter.csv` (231 recordings) + +Tests run these exact files through A1's CSV parser, the modulation owner's calibrated +optical-log-sine/lobe/DAC calculations, the real mean → frequency → depth retarget order, and the +photodiode's production ring-capacity calculation. The photodiode ring sizes itself to the marker +period, so the 0.075 Hz rungs need no cache length set by hand (ADR 033). Before pressing Start, arm +a valid calibrated optical-log-sine drive with **`a <= 1.70`** and complete the connection, fresh +anchor, lease, storage, laser and HV checks in the selected file's header. Static validation cannot +prove those live bench conditions. + `protocols/example.csv` and `example.toml` are commented files to copy, installed to `~/.augur/plugins/stage-a-a1/protocols/`. See [ADR 027](../../docs/adr/027-stage-a-a1-declarative-protocols.md). diff --git a/plugins/stage-a-a1/plugin.toml b/plugins/stage-a-a1/plugin.toml index 4515f43..1d62766 100644 --- a/plugins/stage-a-a1/plugin.toml +++ b/plugins/stage-a-a1/plugin.toml @@ -6,4 +6,10 @@ domain = "stage-a" library = "augur_plugin_stage_a_a1" phase = "raw_events" min_augur_version = "1.0.0" -host_commands = ["start_recording", "stop_recording"] +host_commands = [ + "start_recording", + "stop_recording", + "apply_biases", + "apply_camera_configuration", + "restore_camera_configuration", +] diff --git a/plugins/stage-a-a1/src/lib.rs b/plugins/stage-a-a1/src/lib.rs index 14acc6a..f9a9b67 100644 --- a/plugins/stage-a-a1/src/lib.rs +++ b/plugins/stage-a-a1/src/lib.rs @@ -4,14 +4,17 @@ //! PDQ writer. Hardware ownership remains with the Stage-A modulation and //! photodiode plugins; this code only validates and analyses immutable inputs. -mod csv; pub mod phase; pub mod protocol; pub mod rates; pub mod response_curve; mod runtime; -pub mod sensor; pub mod types; +/// Host sensor-telemetry compaction. Shared with A4 through the contract +/// crate, because both workflows gather the same host-written CSV and a second +/// copy would drift the moment the host adds a column. +pub use stage_a_plugin_contract::{csv, telemetry as sensor}; + pub use runtime::StageAA1Plugin; pub use types::{CameraEvent, Polarity}; diff --git a/plugins/stage-a-a1/src/protocol.rs b/plugins/stage-a-a1/src/protocol.rs index 2851eba..1fa6f6f 100644 --- a/plugins/stage-a-a1/src/protocol.rs +++ b/plugins/stage-a-a1/src/protocol.rs @@ -60,7 +60,10 @@ pub const MAX_POINTS: usize = 4_096; /// still see which line was wrong. const DEPTH_A_RANGE: (f64, f64) = (0.01, 6.0); const MEAN_U_RANGE: (f64, f64) = (0.01, 1.0); -const FREQUENCY_RANGE: (f64, f64) = (0.01, 2_000.0); +const FREQUENCY_RANGE: (f64, f64) = ( + stage_a_plugin_contract::DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 / 1_000.0, +); const DURATION_RANGE: (i64, i64) = (1, 3_600); const SETTLE_RANGE: (f64, f64) = (0.0, 60.0); @@ -102,6 +105,10 @@ pub struct ProtocolPoint { pub duration_s: i64, pub settle_s: f64, pub role: PointRole, + /// Optional per-point contrast-threshold offsets. These are the host's + /// canonical `diff_on`/`diff_off` values, not absolute sensor codes. + pub diff_on: Option, + pub diff_off: Option, } impl ProtocolPoint { @@ -121,6 +128,13 @@ impl ProtocolPoint { pub struct Protocol { pub name: String, pub points: Vec, + pub camera: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CameraSelection { + NamedProfile(String), + Snapshot(augur_plugin_api::CameraConfigurationSnapshotV1), } impl Protocol { @@ -190,6 +204,16 @@ struct ProtocolDoc { defaults: Defaults, #[serde(default, rename = "block")] blocks: Vec, + #[serde(default)] + camera: Option, +} + +#[derive(Debug, Deserialize)] +struct CameraDoc { + #[serde(default)] + profile: Option, + #[serde(default)] + snapshot: Option, } #[derive(Debug, Default, Deserialize)] @@ -198,6 +222,10 @@ struct Defaults { duration_s: Option, #[serde(default)] settle_s: Option, + #[serde(default)] + diff_on: Option, + #[serde(default)] + diff_off: Option, } #[derive(Debug, Deserialize)] @@ -211,6 +239,10 @@ struct BlockDoc { duration_s: Option, #[serde(default)] settle_s: Option, + #[serde(default)] + diff_on: Option, + #[serde(default)] + diff_off: Option, } /// One axis: an explicit list, a single value, or a generated range. @@ -311,11 +343,28 @@ fn strip_bom(text: &str) -> &str { /// Parses a protocol and expands it into the points to record. pub fn parse(text: &str) -> Result { - let doc: ProtocolDoc = toml::from_str(strip_bom(text)) - .map_err(|error| ProtocolError::Toml(error.to_string()))?; + let doc: ProtocolDoc = + toml::from_str(strip_bom(text)).map_err(|error| ProtocolError::Toml(error.to_string()))?; let default_duration = doc.defaults.duration_s.unwrap_or(10); let default_settle = doc.defaults.settle_s.unwrap_or(2.0); + let camera = match doc.camera { + None => None, + Some(CameraDoc { + profile: Some(profile), + snapshot: None, + }) if !profile.trim().is_empty() => Some(CameraSelection::NamedProfile(profile)), + Some(CameraDoc { + profile: None, + snapshot: Some(snapshot), + }) => Some(CameraSelection::Snapshot(snapshot)), + Some(_) => { + return Err(ProtocolError::Invalid { + what: "camera".into(), + detail: "provide exactly one non-empty profile or snapshot".into(), + }); + } + }; let mut points = Vec::new(); // Blocks may be named or not; unnamed ones get a stable positional name so @@ -357,6 +406,10 @@ pub fn parse(text: &str) -> Result { ), }); } + let diff_on = block.diff_on.or(doc.defaults.diff_on); + let diff_off = block.diff_off.or(doc.defaults.diff_off); + validate_bias("diff_on", diff_on)?; + validate_bias("diff_off", diff_off)?; let mean_u = block .mean_u @@ -380,6 +433,8 @@ pub fn parse(text: &str) -> Result { duration_s, settle_s, role: PointRole::Normal, + diff_on, + diff_off, }); if points.len() > MAX_POINTS { return Err(ProtocolError::TooManyPoints(points.len())); @@ -398,6 +453,7 @@ pub fn parse(text: &str) -> Result { .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| "protocol".to_owned()), points, + camera, }) } @@ -439,7 +495,15 @@ pub fn parse_file(path: &str, text: &str) -> Result { /// Columns a protocol CSV may carry. `mean_u`, `frequency_hz` and `depth_a` are /// required; the rest fall back to their defaults. const CSV_REQUIRED: [&str; 3] = ["mean_u", "frequency_hz", "depth_a"]; -const CSV_OPTIONAL: [&str; 4] = ["duration_s", "settle_s", "label", "role"]; +const CSV_OPTIONAL: [&str; 7] = [ + "duration_s", + "settle_s", + "label", + "role", + "camera_profile", + "diff_on", + "diff_off", +]; /// Parses the row-per-recording CSV form. /// @@ -454,6 +518,7 @@ const CSV_OPTIONAL: [&str; 4] = ["duration_s", "settle_s", "label", "role"]; pub fn parse_csv(text: &str) -> Result { let mut header: Option> = None; let mut points = Vec::new(); + let mut camera_profile: Option = None; // `lines()` already absorbs CRLF; the BOM is the part it leaves behind. for (offset, raw) in strip_bom(text).lines().enumerate() { @@ -548,6 +613,35 @@ pub fn parse_csv(text: &str) -> Result { ), })?; let label = cell("label").unwrap_or("").trim().to_owned(); + let row_profile = cell("camera_profile").unwrap_or("").trim(); + if !row_profile.is_empty() { + match &camera_profile { + Some(existing) if existing != row_profile => { + return Err(ProtocolError::Invalid { + what: format!("line {line_no}: camera_profile"), + detail: format!( + "'{row_profile}' differs from the series profile '{existing}'" + ), + }); + } + None => camera_profile = Some(row_profile.to_owned()), + _ => {} + } + } + let parse_bias = |name: &str| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + let value = raw.parse::().map_err(|_| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail: format!("'{raw}' is not a signed integer offset"), + })?; + validate_bias(&format!("line {line_no}: {name}"), Some(value))?; + Ok(Some(value)) + }; + let diff_on = parse_bias("diff_on")?; + let diff_off = parse_bias("diff_off")?; points.push(ProtocolPoint { block: if label.is_empty() { @@ -561,6 +655,8 @@ pub fn parse_csv(text: &str) -> Result { duration_s, settle_s, role, + diff_on, + diff_off, }); if points.len() > MAX_POINTS { return Err(ProtocolError::TooManyPoints(points.len())); @@ -583,9 +679,20 @@ pub fn parse_csv(text: &str) -> Result { Ok(Protocol { name: "protocol".to_owned(), points, + camera: camera_profile.map(CameraSelection::NamedProfile), }) } +fn validate_bias(what: &str, value: Option) -> Result<(), ProtocolError> { + if let Some(value) = value.filter(|value| !(-85..=140).contains(value)) { + return Err(ProtocolError::Invalid { + what: what.to_owned(), + detail: format!("{value} is outside the supported -85..=140 offset range"), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -689,6 +796,122 @@ duration_s = 30 assert_eq!(ladder.duration_s, 30); // settle_s was not overridden, so the default still applies. assert!((ladder.settle_s - 1.5).abs() < f64::EPSILON); + assert_eq!(protocol.camera, None); + assert!(protocol + .points + .iter() + .all(|point| point.diff_on.is_none() && point.diff_off.is_none())); + } + + #[test] + fn a_named_camera_profile_and_canonical_bias_offsets_are_parsed() { + let protocol = parse( + r#" +name = "camera-series" + +[camera] +profile = "A1 low noise" + +[defaults] +diff_on = 12 +diff_off = -7 + +[[block]] +name = "first" +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 + +[[block]] +name = "override" +mean_u = 0.5 +frequency_hz = 20.0 +depth_a = 0.5 +diff_on = 20 +"#, + ) + .expect("camera protocol"); + assert_eq!( + protocol.camera, + Some(CameraSelection::NamedProfile("A1 low noise".into())) + ); + assert_eq!(protocol.points[0].diff_on, Some(12)); + assert_eq!(protocol.points[0].diff_off, Some(-7)); + assert_eq!(protocol.points[1].diff_on, Some(20)); + assert_eq!(protocol.points[1].diff_off, Some(-7)); + } + + #[test] + fn out_of_range_bias_offsets_are_refused_before_the_run() { + let error = parse( + r#" +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 +diff_on = 141 +"#, + ) + .expect_err("invalid bias"); + assert!(error.to_string().contains("diff_on"), "{error}"); + assert!(error.to_string().contains("-85..=140"), "{error}"); + } + + #[test] + fn an_inline_camera_snapshot_roundtrips_through_toml() { + let protocol = parse( + r#" +[camera.snapshot] +schema_version = 1 +masked_pixels = [[3, 4]] + +[camera.snapshot.biases] +diff_on = 12 +diff_off = -7 +fo = 0 +hpf = 0 +refr = 0 + +[camera.snapshot.roi] +x = 0 +y = 0 +width = 1280 +height = 720 + +[camera.snapshot.digital_filter] +stc_enabled = false +stc_threshold_us = 0 +trail_enabled = false + +[camera.snapshot.external_trigger] +enabled = false +channel = 0 + +[camera.snapshot.global] +nm_per_pixel = 1000.0 +pixel_scale_calibrated = true +sensor_width = 1280 +sensor_height = 720 +acq_time_ms = 1 +event_store_budget_mib = 512 +preview_interval_ms = 16 +point_cloud_interval_ms = 50 +disk_writer_buffer_mib = 64 +record_sensor_telemetry = true + +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 +"#, + ) + .expect("inline snapshot protocol"); + let Some(CameraSelection::Snapshot(snapshot)) = protocol.camera else { + panic!("inline snapshot was not selected"); + }; + assert_eq!(snapshot.biases.diff_on, 12); + assert!(snapshot.global.record_sensor_telemetry); + assert_eq!(snapshot.masked_pixels, vec![(3, 4)]); } #[test] @@ -810,6 +1033,8 @@ depth_a = 1.0 duration_s: 5, settle_s: 1.0, role: PointRole::Normal, + diff_on: None, + diff_off: None, }; assert_eq!(point.tag(), "u500m_f12p5Hz_a750m"); assert!(!point.tag().contains('.')); @@ -852,6 +1077,30 @@ slow,0.4,1,0.8,40,4, assert!((protocol.points[1].settle_s - 2.0).abs() < f64::EPSILON); } + #[test] + fn csv_carries_one_series_profile_and_per_point_diff_offsets() { + let csv = "camera_profile,mean_u,frequency_hz,depth_a,diff_on,diff_off\n\ +A1 low noise,0.5,10,0.5,12,-7\n\ +A1 low noise,0.5,20,0.5,20,-8\n"; + let protocol = parse_csv(csv).expect("camera CSV"); + assert_eq!( + protocol.camera, + Some(CameraSelection::NamedProfile("A1 low noise".into())) + ); + assert_eq!(protocol.points[0].diff_on, Some(12)); + assert_eq!(protocol.points[1].diff_off, Some(-8)); + } + + #[test] + fn csv_refuses_profile_changes_within_one_measurement_series() { + let csv = "camera_profile,mean_u,frequency_hz,depth_a\n\ +profile-a,0.5,10,0.5\n\ +profile-b,0.5,20,0.5\n"; + let error = parse_csv(csv).expect_err("two series profiles"); + assert!(error.to_string().contains("profile-b"), "{error}"); + assert!(error.to_string().contains("profile-a"), "{error}"); + } + #[test] fn a_row_can_name_its_role_so_a_file_carries_its_own_references() { let protocol = parse_csv(SAMPLE).expect("valid CSV"); diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index 18e1ead..60ed1ea 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -43,25 +43,26 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use augur_plugin_api::{ - export_plugin, EventStoreHandle, FfiCdEvent, GlobalSettings, HostCommand, HostCommandOutcome, + export_plugin, CameraConfigurationProvenanceV1, CameraConfigurationSnapshotV1, + EventStoreHandle, FfiCdEvent, GlobalSettings, HostCommand, HostCommandOutcome, HostCommandReply, HostCommandRequest, HostContext, HostDatasetDescriptor, HostDatasetKind, HostOutput, HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, PathDialogKind, Plugin, PluginCapabilities, PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, PluginInput, PluginRuntimeRole, PluginServiceOutcome, - PluginServiceReply, PluginServiceRequest, RoiV1, SensorMonitoringV1, Series1dLine, - Series1dPoint, Series1dV1, SettingItem, SettingKind, SettingsSchema, SettingsSection, - StatusEntry, TableColumn, TableColumnData, TableColumnValues, TableDatasetV1, TableSchema, - TableValueType, CTX_GLOBAL_SETTINGS, CTX_SENSOR_MONITORING, + PluginServiceReply, PluginServiceRequest, RoiV1, SensorBiasOffsetsV1, SensorBiasReadbackV1, + SensorMonitoringV1, Series1dLine, Series1dPoint, Series1dV1, SettingItem, SettingKind, + SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, TableColumnValues, + TableDatasetV1, TableSchema, TableValueType, CTX_GLOBAL_SETTINGS, CTX_SENSOR_MONITORING, }; use serde::Serialize; use serde_json::{json, Value}; use stage_a_plugin_contract::{ - ClientId, ConnectionStateV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, - ModulationStateV1, OpticalTargetV1, PdqReceiptV1, PdqStartSpecV1, PhotodiodeCommandV1, - PhotodiodeOpticalSummaryV1, PhotodiodeRequestV1, PhotodiodeResponseV1, PhotodiodeSummaryV1, - RequestId, RunId, SemanticRevision, WaveformV1, CTX_STAGE_A_MODULATION_STATE_V1, - CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, SERVICE_STAGE_A_MODULATION_CONTROL_V1, - SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, + ClientId, ConnectionStateV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, + ModulationRequestV1, ModulationStateV1, OpticalTargetV1, PdqReceiptV1, PdqStartSpecV1, + PhotodiodeCommandV1, PhotodiodeOpticalSummaryV1, PhotodiodeRequestV1, PhotodiodeResponseV1, + PhotodiodeSummaryV1, RequestId, RunId, SemanticRevision, WaveformV1, + CTX_STAGE_A_MODULATION_STATE_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + SERVICE_STAGE_A_MODULATION_CONTROL_V1, SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, }; use crate::phase::{fold_events, fold_events_free_running, MarkerValidationConfig, PhaseFold}; @@ -379,6 +380,18 @@ struct Recording { /// First thing that went wrong, kept verbatim so the closing message names /// the cause instead of only reporting that the run was incomplete. failure: Option, + /// The newest optical summary seen while this recording was running. + /// + /// The sidecar's optical section describes the light *during the recording*, + /// so it is latched here rather than re-read live when the metadata is + /// written. Between the last sample and that write sit the photodiode + /// finalize, the camera finalize and a gather that may copy a multi-gigabyte + /// RAW across volumes — all of it blocking this plugin's own control tick, + /// so no snapshot arrives while it runs. Read live, the owner's 2 s + /// freshness budget then expires against wall-clock time that the recording + /// spent finalizing, and a finished recording lost its sidecar for having + /// been *large* (ADR 034). + optical: Option, } /// Where the amplitude sweep is within its per-point cycle. @@ -805,6 +818,8 @@ impl FreqSweep { /// Where a protocol run is within its per-point cycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProtocolPhase { + /// The host is resolving/applying the named profile or inline snapshot. + ApplyingCamera, /// AcquireLease sent to the modulation owner; waiting for the grant. AcquiringLease, /// The three retargets for this point are in flight; waiting for all of @@ -814,6 +829,9 @@ enum ProtocolPhase { Settling, /// The recording coordinator owns this phase. Recording, + /// All recording work is complete; the host is restoring the pre-run + /// configuration (or A1 is restoring the original point biases). + RestoringCamera, } /// One protocol run: walk the parsed points, retargeting all three axes at @@ -831,6 +849,17 @@ struct ProtocolRun { lease_id: LeaseId, lease_granted: bool, lease_req: u64, + camera_apply_req: Option, + camera_session_active: bool, + camera_snapshot: Option, + camera_provenance: Option, + camera_confirmation: Option<(SensorBiasReadbackV1, f64)>, + bias_req: Option, + bias_confirmation: Option<(SensorBiasOffsetsV1, SensorBiasReadbackV1, f64)>, + original_biases: Option, + biases_changed: bool, + restore_req: Option, + finish_message: Option, /// Request ids of the retargets in flight for the current point. A point /// only proceeds once this is empty: the three axes are applied /// independently, and recording after two of them would file the run under @@ -838,6 +867,7 @@ struct ProtocolRun { pending_reqs: Vec, /// Wall-clock instant the dwell ends. settle_until_ms: u64, + settle_started_ms: u64, /// Points whose retarget or recording failed, with the owner's own reason. /// /// Kept rather than aborting — the rest of the survey is still worth @@ -889,6 +919,9 @@ pub struct StageAA1Plugin { // -- host camera ROI/mask, mirrored from CTX_GLOBAL_SETTINGS -- host_roi: Option, masked_pixels: HashSet<(u16, u16)>, + /// Host-owned recording switch mirrored from `CTX_GLOBAL_SETTINGS`. + /// Bias-only protocols require it so every point keeps its sensor history. + record_sensor_telemetry: bool, /// Latest sensor-measured die temperature, pixel dead time and scene /// illumination, mirrored from `CTX_SENSOR_MONITORING` every frame. /// @@ -1040,6 +1073,7 @@ impl Default for StageAA1Plugin { frame_height: 0, host_roi: None, masked_pixels: HashSet::new(), + record_sensor_telemetry: false, sensor: None, sensor_at_start: None, window_floor: DEFAULT_WINDOW_FLOOR, @@ -1139,6 +1173,7 @@ impl Recording { pd_valid: false, pd_rejected: false, failure: None, + optical: None, } } @@ -1637,40 +1672,51 @@ impl StageAA1Plugin { } fn photodiode_a_blocker(&self) -> Option { - if self.photodiode_a().is_some() { - return None; - } // Every branch names the photodiode gate to fix *and* the way past it, // because a bench that cannot produce a measured `a` at all — no // trigger markers, say — otherwise leaves the operator with a correct // diagnosis and no next step. - let fallback = " (or switch \"Depth a source\" to the commanded drive to work open loop)"; + let reason = self.optical_summary_blocker()?; + Some(format!( + "{reason} (or switch \"Depth a source\" to the commanded drive to work open loop)" + )) + } + + /// Why the photodiode is publishing no optical summary, in the owner's own + /// words where it has any. `None` means it is publishing one. + /// + /// Separate from [`Self::photodiode_a_blocker`] because not every caller can + /// offer the open-loop way out: the sidecar needs this summary whichever + /// depth source is selected, so telling the operator to switch sources there + /// would name an escape that does not exist. + fn optical_summary_blocker(&self) -> Option { + if self.fresh_optical_summary().is_some() { + return None; + } let Some(state) = self.photodiode.as_ref() else { - return Some(format!( + return Some( "the photodiode plugin is not reporting status — enable it and connect the \ - detector{fallback}" - )); + detector" + .into(), + ); }; if !matches!(state.connection, ConnectionStateV1::Connected { .. }) { return Some(format!( - "the photodiode is {} — connect it{fallback}", + "the photodiode is {} — connect it", connection_label(&state.connection) )); } if state.freshness.is_stale_at(now_unix_ms()) { - return Some(format!( - "the photodiode status snapshot is stale — check that the stream is \ - running{fallback}" - )); + return Some( + "the photodiode status snapshot is stale — check that the stream is running".into(), + ); } // The owner's own words: it is the only side that knows which estimator // gate rejected the window. if let Some(reason) = state.optical_unavailable.as_deref() { - return Some(format!("{reason}{fallback}")); + return Some(reason.to_owned()); } - Some(format!( - "the photodiode is streaming no samples yet — start the stream{fallback}" - )) + Some("the photodiode is streaming no samples yet — start the stream".into()) } fn commanded_a_blocker(&self) -> Option { @@ -2112,6 +2158,47 @@ impl StageAA1Plugin { if let Some(n) = self.valid_pixel_count() { meta.insert("n_valid".into(), n.to_string()); } + if let Some(run) = self.protocol.as_ref() { + meta.insert("a1_protocol_name".into(), run.plan.name.clone()); + meta.insert("a1_protocol_point".into(), (run.index + 1).to_string()); + if let Some(provenance) = run.camera_provenance.as_ref() { + meta.insert( + "camera_configuration_sha256".into(), + provenance.sha256.clone(), + ); + meta.insert( + "camera_configuration_schema_version".into(), + provenance.schema_version.to_string(), + ); + if let Some(name) = provenance.profile_name.as_ref() { + meta.insert("camera_profile_name".into(), name.clone()); + } + if let Some(revision) = provenance.profile_revision { + meta.insert("camera_profile_revision".into(), revision.to_string()); + } + } + if let Some(point) = run.point() { + if let Some(diff_on) = point.diff_on { + meta.insert("requested_diff_on".into(), diff_on.to_string()); + } + if let Some(diff_off) = point.diff_off { + meta.insert("requested_diff_off".into(), diff_off.to_string()); + } + } + if let Some((applied, readback, age_s)) = run.bias_confirmation { + meta.insert("confirmed_diff_on".into(), applied.diff_on.to_string()); + meta.insert("confirmed_diff_off".into(), applied.diff_off.to_string()); + meta.insert( + "confirmed_diff_on_code".into(), + readback.current.diff_on.to_string(), + ); + meta.insert( + "confirmed_diff_off_code".into(), + readback.current.diff_off.to_string(), + ); + meta.insert("bias_readback_age_s".into(), format!("{age_s:.3}")); + } + } // Bench conditions, on every run and every role. Each key appears only // when the sensor actually reported that quantity — an absent reading // must not arrive downstream as 0 °C or 0 lux. @@ -2193,6 +2280,36 @@ impl StageAA1Plugin { None } + /// Separates "the controller can output this frequency" from "the current + /// photodiode stream can resolve it as an A1 waveform". The latter needs a + /// fresh, explicit sample rate and at least 16 samples per cycle; a Nyquist + /// pass with two samples would not support peak/trough or waveform fitting. + fn photodiode_measurement_blocker(&self, frequency_hz: f64) -> Option { + let photodiode = self.photodiode.as_ref()?; + if photodiode.freshness.is_stale_at(now_unix_ms()) { + return Some( + "The photodiode sample-rate reading is stale — restart or check the stream".into(), + ); + } + let Some(sample_rate_hz) = photodiode.stream.sample_rate_hz else { + return Some( + "The photodiode reports no sample rate, so A1 cannot prove this frequency is measurable" + .into(), + ); + }; + let limit = stage_a_plugin_contract::a1_measurement_frequency_limit_hz(sample_rate_hz); + if frequency_hz > limit { + return Some(format!( + "The drive can output {}, but the photodiode is sampling at {} Sa/s: A1 requires at least {} samples/cycle, so the measurable limit is {}", + frequency_label(frequency_hz), + sample_rate_hz, + stage_a_plugin_contract::A1_MIN_SAMPLES_PER_CYCLE, + frequency_label(limit), + )); + } + None + } + /// Kick off a coordinated recording by starting the camera first. Called /// on the control tick after a record button is pressed. fn begin_recording(&mut self, context: &mut impl RecordingControl, role: RecRole) { @@ -2210,6 +2327,12 @@ impl StageAA1Plugin { self.note(blocker); return; } + if let Some(hz) = self.frequency_hz() { + if let Some(blocker) = self.photodiode_measurement_blocker(hz) { + self.note(blocker); + return; + } + } let now_ms = now_unix_ms(); // Freeze the bench conditions this run begins under, before any of the // start handshake has had time to move them. @@ -2567,7 +2690,7 @@ impl StageAA1Plugin { return None; } let destination = dir.join(format!("{}.sensor.json", self.recording.stem)); - let json = readout.to_json(&self.recording.id, &self.recording.stem); + let json = readout.to_json(sensor::SCHEMA_A1, &self.recording.id, &self.recording.stem); if std::fs::write(&destination, json).is_err() { return None; } @@ -4462,45 +4585,190 @@ impl StageAA1Plugin { return; } } + let highest = plan + .points + .iter() + .map(|point| point.frequency_hz) + .fold(0.0_f64, f64::max); + if let Some(blocker) = self.photodiode_measurement_blocker(highest) { + self.message = format!( + "Protocol refused at its highest frequency ({}): {blocker}", + frequency_label(highest) + ); + return; + } + + let controls_camera = plan.camera.is_some() + || plan + .points + .iter() + .any(|point| point.diff_on.is_some() || point.diff_off.is_some()); + if controls_camera && plan.camera.is_none() && !self.record_sensor_telemetry { + self.message = + "Protocol refused: enable Record sensor monitoring before a bias-controlled run" + .into(); + return; + } + let original_biases = if controls_camera { + let Some(sensor) = self.sensor.filter(|sensor| sensor.age_s <= 2.0) else { + self.message = + "Protocol refused: camera bias control requires a fresh Sensor reading".into(); + return; + }; + let Some(readback) = sensor.bias_codes else { + self.message = + "Protocol refused: the Sensor reading contains no bias readback".into(); + return; + }; + Some(SensorBiasOffsetsV1 { + diff_on: i32::from(readback.current.diff_on) + - i32::from(readback.factory_default.diff_on), + diff_off: i32::from(readback.current.diff_off) + - i32::from(readback.factory_default.diff_off), + }) + } else { + None + }; let now_ms = now_unix_ms(); let lease_id = LeaseId::new(format!( "a1-protocol-{}", format_compact_utc(now_ms / 1_000) )); - let ttl_ms = Self::protocol_lease_ttl_ms(&plan, 0); - let request = - self.modulation_request(ModulationCommandV1::AcquireLease { ttl_ms }, &lease_id); - let lease_req = request.request_id; - context.request_service(&request); + let camera_selection = plan.camera.clone(); + let phase = if camera_selection.is_some() { + ProtocolPhase::ApplyingCamera + } else { + ProtocolPhase::AcquiringLease + }; let (means, frequencies, depths) = plan.axis_counts(); let total = plan.points.len(); let minutes = plan.total_seconds() / 60.0; self.message = format!( "Protocol '{}': {total} recordings ({means} × ū, {frequencies} × f, {depths} × a), \ - about {minutes:.0} min of bench time — acquiring the modulation lease…", + about {minutes:.0} min of bench time — preparing the camera and modulation lease…", plan.name ); self.protocol = Some(ProtocolRun { plan, - phase: ProtocolPhase::AcquiringLease, + phase, index: 0, lease_id, lease_granted: false, - lease_req, + lease_req: 0, + camera_apply_req: None, + camera_session_active: false, + camera_snapshot: None, + camera_provenance: None, + camera_confirmation: None, + bias_req: None, + bias_confirmation: None, + original_biases, + biases_changed: false, + restore_req: None, + finish_message: None, pending_reqs: Vec::new(), settle_until_ms: 0, + settle_started_ms: 0, failed: Vec::new(), recorded: 0, last_activity_ms: now_ms, stop_requested: false, skip_reason: None, }); + if let Some(selection) = camera_selection { + let request_id = self.next_request_id(); + let command = match selection { + protocol::CameraSelection::NamedProfile(profile_name) => { + HostCommand::ApplyCameraConfiguration { + profile_name: Some(profile_name), + snapshot: None, + } + } + protocol::CameraSelection::Snapshot(snapshot) => { + HostCommand::ApplyCameraConfiguration { + profile_name: None, + snapshot: Some(snapshot), + } + } + }; + context.request_host(&HostCommandRequest { + request_id, + command, + }); + if let Some(run) = self.protocol.as_mut() { + run.camera_apply_req = Some(request_id); + // A missing reply is ambiguous: the host may have applied the + // configuration before its reply was lost. Treat the session + // as active until a restore is confirmed, so timeout and abort + // paths also fail safe. + run.camera_session_active = true; + } + } else { + self.acquire_protocol_lease(context); + } + } + + fn acquire_protocol_lease(&mut self, context: &mut impl RecordingControl) { + let Some(run) = self.protocol.as_ref() else { + return; + }; + let ttl_ms = Self::protocol_lease_ttl_ms(&run.plan, run.index); + let lease_id = run.lease_id.clone(); + let request = + self.modulation_request(ModulationCommandV1::AcquireLease { ttl_ms }, &lease_id); + context.request_service(&request); + if let Some(run) = self.protocol.as_mut() { + run.phase = ProtocolPhase::AcquiringLease; + run.lease_req = request.request_id; + run.last_activity_ms = now_unix_ms(); + } } - /// Release the protocol's lease (if this run holds it) and clear it. + /// Restore camera state before releasing the protocol lease and clearing + /// the run. No success, stop, or abort path bypasses this function. fn finish_protocol(&mut self, context: &mut impl RecordingControl, message: String) { + let Some(run) = self.protocol.as_ref() else { + self.message = message; + return; + }; + if run.phase == ProtocolPhase::RestoringCamera { + return; + } + + let restore_command = if run.camera_session_active { + Some(HostCommand::RestoreCameraConfiguration) + } else if run.biases_changed { + run.original_biases.map(|biases| HostCommand::ApplyBiases { + diff_on: Some(biases.diff_on), + diff_off: Some(biases.diff_off), + }) + } else { + None + }; + if let Some(command) = restore_command { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command, + }); + if let Some(run) = self.protocol.as_mut() { + run.phase = ProtocolPhase::RestoringCamera; + run.restore_req = Some(request_id); + run.finish_message = Some(message); + run.last_activity_ms = now_unix_ms(); + } + self.message = + "Protocol stopped recording; restoring the pre-run camera settings…".into(); + return; + } + + self.complete_protocol(context, message); + } + + /// Release the modulation lease after camera restoration has resolved. + fn complete_protocol(&mut self, context: &mut impl RecordingControl, message: String) { if let Some(run) = self.protocol.take() { if run.lease_granted { let request = self.modulation_request( @@ -4551,6 +4819,20 @@ impl StageAA1Plugin { context.request_service(&request); } + let bias_request = if point.diff_on.is_some() || point.diff_off.is_some() { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyBiases { + diff_on: point.diff_on, + diff_off: point.diff_off, + }, + }); + Some(request_id) + } else { + None + }; + // The retained markers and events belong to the previous point's // frequency; the measured period is their mean spacing, so leaving // them would confirm this point against a mixture of the two. Pilot @@ -4564,6 +4846,9 @@ impl StageAA1Plugin { if let Some(run) = self.protocol.as_mut() { run.phase = ProtocolPhase::Retargeting; run.pending_reqs = pending; + run.bias_req = bias_request; + run.bias_confirmation = None; + run.biases_changed |= bias_request.is_some(); run.skip_reason = None; run.last_activity_ms = now_ms; } @@ -4653,13 +4938,26 @@ impl StageAA1Plugin { self.message = "A protocol is already running — press Stop to end it".into(); } let now_ms = now_unix_ms(); - let (phase, stop_requested, lease_granted, retargets_left, settle_until_ms, last_activity) = { + let ( + phase, + stop_requested, + lease_granted, + lease_req, + retargets_left, + bias_pending, + restore_pending, + settle_until_ms, + last_activity, + ) = { let run = self.protocol.as_ref().expect("run checked above"); ( run.phase, run.stop_requested, run.lease_granted, + run.lease_req, run.pending_reqs.len(), + run.bias_req.is_some(), + run.restore_req.is_some(), run.settle_until_ms, run.last_activity_ms, ) @@ -4668,17 +4966,38 @@ impl StageAA1Plugin { // A stop waits for the recording in flight to wind down, then ends the // run — a protocol that abandoned a half-written file would leave a // truncated RAW behind. - if stop_requested { + if stop_requested && phase != ProtocolPhase::RestoringCamera { if self.recording.is_active() { self.recording.stop_requested = true; return; } + if let Some(message) = self + .protocol + .as_mut() + .and_then(|run| run.finish_message.take()) + { + self.finish_protocol(context, message); + return; + } self.advance_protocol(context); return; } match phase { + ProtocolPhase::ApplyingCamera => { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + self.finish_protocol( + context, + "Protocol aborted: the host did not confirm the camera configuration" + .into(), + ); + } + } ProtocolPhase::AcquiringLease => { + if lease_req == 0 { + self.acquire_protocol_lease(context); + return; + } if !lease_granted { if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { self.finish_protocol( @@ -4700,11 +5019,11 @@ impl StageAA1Plugin { self.fail_protocol_point(context, reason); return; } - if retargets_left > 0 { + if retargets_left > 0 || bias_pending { if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { self.fail_protocol_point( context, - "the modulation plugin did not apply the requested drive".into(), + "the requested drive or camera biases were not confirmed".into(), ); } return; @@ -4718,6 +5037,7 @@ impl StageAA1Plugin { if let Some(run) = self.protocol.as_mut() { run.phase = ProtocolPhase::Settling; run.settle_until_ms = now_ms.saturating_add(settle_ms); + run.settle_started_ms = now_ms; run.last_activity_ms = now_ms; } } @@ -4777,6 +5097,33 @@ impl StageAA1Plugin { self.fail_protocol_point(context, reason); } } + ProtocolPhase::RestoringCamera => { + if restore_pending { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + let message = self + .protocol + .as_ref() + .and_then(|run| run.finish_message.clone()) + .unwrap_or_else(|| "Protocol ended".into()); + self.complete_protocol( + context, + format!( + "{message} — ERROR: pre-run camera settings were not confirmed restored" + ), + ); + } + return; + } + let message = self + .protocol + .as_mut() + .and_then(|run| run.finish_message.take()) + .unwrap_or_else(|| "Protocol ended; pre-run camera settings restored".into()); + self.complete_protocol( + context, + format!("{message} — pre-run camera settings restored"), + ); + } } } @@ -5130,6 +5477,119 @@ impl StageAA1Plugin { } fn on_host_reply(&mut self, reply: &HostCommandReply) { + let protocol_requests = self + .protocol + .as_ref() + .map(|run| (run.camera_apply_req, run.bias_req, run.restore_req)); + if let Some((camera_apply_req, bias_req, restore_req)) = protocol_requests { + if camera_apply_req == Some(reply.request_id) { + let now_ms = now_unix_ms(); + match &reply.outcome { + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, + readback, + readback_age_s, + } => { + if let Some(run) = self.protocol.as_mut() { + run.camera_apply_req = None; + run.camera_session_active = true; + run.camera_snapshot = Some(snapshot.clone()); + run.camera_provenance = Some(provenance.clone()); + run.camera_confirmation = Some((*readback, *readback_age_s)); + run.phase = ProtocolPhase::AcquiringLease; + run.lease_req = 0; + run.last_activity_ms = now_ms; + } + } + HostCommandOutcome::Rejected { code, message } => { + if let Some(run) = self.protocol.as_mut() { + run.camera_apply_req = None; + run.stop_requested = true; + run.finish_message = Some(format!( + "Protocol aborted: camera configuration rejected ({code}): {message}" + )); + run.last_activity_ms = now_ms; + } + } + _ => { + if let Some(run) = self.protocol.as_mut() { + run.camera_apply_req = None; + run.stop_requested = true; + run.finish_message = Some( + "Protocol aborted: host returned an invalid camera-configuration reply" + .into(), + ); + run.last_activity_ms = now_ms; + } + } + } + return; + } + if bias_req == Some(reply.request_id) { + let now_ms = now_unix_ms(); + match &reply.outcome { + HostCommandOutcome::BiasesApplied { + applied, + readback, + readback_age_s, + } => { + if let Some(run) = self.protocol.as_mut() { + run.bias_req = None; + run.bias_confirmation = Some((*applied, *readback, *readback_age_s)); + run.last_activity_ms = now_ms; + } + } + HostCommandOutcome::Rejected { code, message } => { + if let Some(run) = self.protocol.as_mut() { + run.bias_req = None; + run.skip_reason = Some(format!( + "camera biases were not confirmed ({code}): {message}" + )); + run.last_activity_ms = now_ms; + } + } + _ => { + if let Some(run) = self.protocol.as_mut() { + run.bias_req = None; + run.skip_reason = + Some("host returned an invalid camera-bias confirmation".into()); + run.last_activity_ms = now_ms; + } + } + } + return; + } + if restore_req == Some(reply.request_id) { + let now_ms = now_unix_ms(); + let restored = matches!( + reply.outcome, + HostCommandOutcome::CameraConfigurationRestored { .. } + | HostCommandOutcome::BiasesApplied { .. } + ); + if let Some(run) = self.protocol.as_mut() { + run.restore_req = None; + run.camera_session_active = false; + run.biases_changed = false; + run.last_activity_ms = now_ms; + if !restored { + let detail = match &reply.outcome { + HostCommandOutcome::Rejected { code, message } => { + format!("restore rejected ({code}): {message}") + } + _ => "host returned an invalid restore confirmation".into(), + }; + let message = run + .finish_message + .take() + .unwrap_or_else(|| "Protocol ended".into()); + run.finish_message = Some(format!("{message} — ERROR: {detail}")); + } + } + return; + } + } + if reply.request_id == self.recording.cam_start_req { match &reply.outcome { HostCommandOutcome::RecordingStarted { @@ -5233,6 +5693,15 @@ impl StageAA1Plugin { /// Advance the recording state machine one control tick. fn drive_recording(&mut self, context: &mut impl RecordingControl) { let now_ms = now_unix_ms(); + // Latch the light while the recording can still see it. Everything after + // the last sample — both finalizes, the gather — blocks this tick, so a + // summary read afterwards is judged stale for time the recording itself + // spent being written out. + if self.recording.is_active() { + if let Some(optical) = self.fresh_optical_summary() { + self.recording.optical = Some(optical.clone()); + } + } match self.recording.phase { RecPhase::Idle => { if let Some(role) = self.pending_role.take() { @@ -5357,13 +5826,26 @@ impl StageAA1Plugin { .modulation .as_ref() .and_then(|state| state.optical_drive.as_ref()); - let optical = self.fresh_optical_summary(); + // The recording's own conditions first, and only then a live read for a + // sidecar written outside one. + let optical = self + .recording + .optical + .as_ref() + .or_else(|| self.fresh_optical_summary()); if optical.is_none() { - return Err( + // The refusal used to stop at "no fresh summary", which reads as a + // missing anchor and sends the operator to re-confirm one that was + // already fine. The owner knows which estimator gate rejected the + // window — a railed detector, too few whole cycles, a stopped + // stream — so hand its sentence on. This is the whole report an + // unattended protocol run leaves behind for the point it lost. + return Err(format!( "cannot write a quantitative A1 sidecar without a fresh photodiode optical \ - summary from a confirmed I_tot anchor" - .into(), - ); + summary from a confirmed I_tot anchor: {}", + self.optical_summary_blocker() + .unwrap_or_else(|| "the photodiode gave no reason".into()) + )); } let roi = self.host_roi.unwrap_or_default(); @@ -5388,7 +5870,12 @@ impl StageAA1Plugin { finalized_at_utc: format_iso_utc(now_ms / 1_000), duration_s: self.recording.duration_s, depth_a_source: self.depth_source.label().into(), - depth_a: self.depth_a(), + // Measured `a` comes from the same latched window as the rest of the + // optical section, so the two can never disagree. + depth_a: match self.depth_source { + DepthSource::Photodiode => optical.map(|o| o.measured_log_contrast), + DepthSource::Commanded => self.commanded_a(), + }, sweep: { let point = self .sweep @@ -5492,8 +5979,44 @@ impl StageAA1Plugin { masked_pixels: self.masked_pixels.len(), n_valid: self.valid_pixel_count(), }, + camera_control: self.protocol.as_ref().and_then(|run| { + let point = run.point(); + let point_requested = + point.is_some_and(|point| point.diff_on.is_some() || point.diff_off.is_some()); + if run.camera_snapshot.is_none() && !point_requested { + return None; + } + let (confirmed_offsets, confirmed_readback, readback_age_s) = run + .bias_confirmation + .map(|(offsets, readback, age_s)| (Some(offsets), Some(readback), Some(age_s))) + .or_else(|| { + run.camera_confirmation.map(|(readback, age_s)| { + let offsets = + run.camera_snapshot + .as_ref() + .map(|snapshot| SensorBiasOffsetsV1 { + diff_on: snapshot.biases.diff_on, + diff_off: snapshot.biases.diff_off, + }); + (offsets, Some(readback), Some(age_s)) + }) + }) + .unwrap_or((None, None, None)); + Some(CameraControlSidecar { + snapshot: run.camera_snapshot.clone(), + provenance: run.camera_provenance.clone(), + requested_diff_on: point.and_then(|point| point.diff_on), + requested_diff_off: point.and_then(|point| point.diff_off), + confirmed_offsets, + confirmed_readback, + readback_age_s, + status: "confirmed".into(), + }) + }), sensor: self.recorded_sensor().map(|sensor| { - let codes = sensor.bias_codes.map(|readback| readback.current); + let readback = sensor.bias_codes; + let codes = readback.map(|readback| readback.current); + let factory = readback.map(|readback| readback.factory_default); SensorSidecar { temperature_c: sensor.temperature_c, pixel_dead_time_us: sensor.pixel_dead_time_us, @@ -5504,6 +6027,11 @@ impl StageAA1Plugin { bias_fo: codes.map(|c| c.fo), bias_hpf: codes.map(|c| c.hpf), bias_refr: codes.map(|c| c.refr), + factory_diff_on: factory.map(|c| c.diff_on), + factory_diff_off: factory.map(|c| c.diff_off), + factory_fo: factory.map(|c| c.fo), + factory_hpf: factory.map(|c| c.hpf), + factory_refr: factory.map(|c| c.refr), } }), trigger: TriggerSidecar { @@ -5564,6 +6092,9 @@ struct SidecarDoc { modulation: ModulationSidecar, optical: OpticalSidecar, camera: CameraSidecar, + /// Host-routed camera settings and fresh readback for this protocol point. + #[serde(skip_serializing_if = "Option::is_none")] + camera_control: Option, /// Absent when the host had no camera able to measure these (replay, /// imports, a sensor without a monitoring block). #[serde(skip_serializing_if = "Option::is_none")] @@ -5727,6 +6258,25 @@ struct CameraSidecar { n_valid: Option, } +#[derive(Serialize)] +struct CameraControlSidecar { + #[serde(skip_serializing_if = "Option::is_none")] + snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provenance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_diff_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_diff_off: Option, + #[serde(skip_serializing_if = "Option::is_none")] + confirmed_offsets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + confirmed_readback: Option, + #[serde(skip_serializing_if = "Option::is_none")] + readback_age_s: Option, + status: String, +} + /// Bench conditions the sensor measured for itself at the start of the run. /// /// Provenance, never an input: the `q_p(a, f)` response depends on the pixel @@ -5761,6 +6311,16 @@ struct SensorSidecar { bias_hpf: Option, #[serde(skip_serializing_if = "Option::is_none")] bias_refr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + factory_diff_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + factory_diff_off: Option, + #[serde(skip_serializing_if = "Option::is_none")] + factory_fo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + factory_hpf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + factory_refr: Option, } #[derive(Serialize)] @@ -6083,6 +6643,7 @@ impl Plugin for StageAA1Plugin { { self.host_roi = Some(settings.roi); self.masked_pixels = settings.masked_pixels.into_iter().collect(); + self.record_sensor_telemetry = settings.record_sensor_telemetry; } // Mirrored above the `live` gate on purpose: these are recorded with // every run, and recordings are made with Live analysis off just as @@ -6490,7 +7051,8 @@ impl Plugin for StageAA1Plugin { ), kind: SettingKind::F64Drag { min: 0.01, - max: 2_000.0, + max: stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 + / 1_000.0, speed: 0.1, default: self.min_f, }, @@ -6505,7 +7067,8 @@ impl Plugin for StageAA1Plugin { ), kind: SettingKind::F64Drag { min: 0.01, - max: 2_000.0, + max: stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 + / 1_000.0, speed: 1.0, default: self.max_f, }, @@ -9499,6 +10062,96 @@ mod tests { let _ = std::fs::remove_dir_all(&folder); } + /// A finished recording must not lose its sidecar for having been *large*. + /// + /// Between the last sample and the sidecar write sit both finalizes and a + /// gather that may copy a multi-gigabyte RAW across volumes, all of it + /// blocking this plugin's own tick — so no photodiode snapshot arrives while + /// it runs. Read live at that moment, the owner's 2 s freshness budget has + /// expired against the recording's own write-out time, and the metadata that + /// makes the RAW and PDQ quantitative is refused. + #[test] + fn the_sidecar_records_the_light_during_the_recording_not_at_write_time() { + let folder = temp_folder("stale-at-write"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.recording.id = "A1-stale".into(); + plugin.recording.stem = "A1-stale_20260807-120000".into(); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.phase = RecPhase::Running; + plugin.recording.duration_s = 100; + plugin.recording.start_unix_ms = now_unix_ms(); + + // While it runs, the owner is publishing. + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let latched = plugin + .recording + .optical + .as_ref() + .expect("a running recording latches the light it is recording under") + .measured_log_contrast; + + // Finalizing took longer than the freshness budget: the last snapshot is + // now old, and nothing newer can arrive because this tick was blocked. + if let Some(summary) = plugin.photodiode.as_mut() { + summary.freshness.observed_at_unix_ms = now_unix_ms() + .saturating_sub(summary.freshness.valid_for_ms) + .saturating_sub(30_000); + } + assert!( + plugin.fresh_optical_summary().is_none(), + "the live read must be stale for this test to mean anything" + ); + + let path = plugin + .write_sidecar() + .expect("the latched summary carries the sidecar"); + let written = std::fs::read_to_string(&path).expect("sidecar readable"); + assert!( + written.contains(&format!("measured_a = {latched}")), + "the sidecar must carry the measured a from the recording: {written}" + ); + assert!( + written.contains(&format!("depth_a = {latched}")), + "the recorded depth must come from the same window: {written}" + ); + let _ = std::fs::remove_dir_all(&folder); + } + + /// The sidecar refusal is the whole report an unattended protocol run leaves + /// behind for a point it lost — and it arrives after the recording has + /// already run. Naming only the anchor sent the operator to re-confirm one + /// that was fine while the real gate (too few whole cycles at a sub-hertz + /// rung) went unnamed for a whole survey. + #[test] + fn a_refused_sidecar_quotes_the_owners_reason_not_just_the_anchor() { + let folder = temp_folder("sidecar-blocker"); + let mut plugin = plugin_locking(1.0, &folder); + if let Some(summary) = plugin.photodiode.as_mut() { + summary.optical_summary = None; + summary.optical_unavailable = Some( + "no stretch of samples covers two whole modulation cycles between triggers \ + (2 trigger(s) in the last 10000000 samples)" + .into(), + ); + } + + let error = plugin + .write_sidecar() + .expect_err("no optical summary must refuse the sidecar"); + assert!( + error.contains("two whole modulation cycles"), + "the refusal must quote the owner: {error}" + ); + // And it must not offer the open-loop escape here: the sidecar needs + // this summary whichever depth source is selected. + assert!( + !error.contains("Depth a source"), + "the sidecar refusal must not name an escape that does not exist: {error}" + ); + let _ = std::fs::remove_dir_all(&folder); + } + #[test] fn the_status_panel_names_live_analysis_when_it_is_off() { // "0 events, free-running" describes the toggle, not the bench, and the @@ -10045,6 +10698,7 @@ mod tests { plugin.measurement_id = "A1-proto".into(); plugin.protocol_path = path.display().to_string(); plugin.protocol_pending = true; + plugin.record_sensor_telemetry = true; (plugin, path) } @@ -10062,6 +10716,376 @@ frequency_hz = 25.0 depth_a = 0.7 "#; + const BIAS_PROTOCOL: &str = r#" +name = "bias-point" + +[defaults] +duration_s = 3 +settle_s = 0.0 + +[[block]] +mean_u = 0.5 +frequency_hz = 25.0 +depth_a = 0.7 +diff_on = 12 +diff_off = -7 +"#; + + const PROFILE_PROTOCOL: &str = r#" +name = "profile-point" + +[camera] +profile = "A1 low noise" + +[[block]] +mean_u = 0.5 +frequency_hz = 25.0 +depth_a = 0.7 +"#; + + fn fresh_bias_sensor(diff_on: u8, diff_off: u8) -> SensorMonitoringV1 { + SensorMonitoringV1 { + bias_codes: Some(SensorBiasReadbackV1 { + current: augur_plugin_api::SensorBiasCodesV1 { + diff_on, + diff_off, + fo: 30, + hpf: 40, + refr: 50, + }, + factory_default: augur_plugin_api::SensorBiasCodesV1 { + diff_on: 100, + diff_off: 100, + fo: 30, + hpf: 40, + refr: 50, + }, + }), + age_s: 0.1, + ..SensorMonitoringV1::default() + } + } + + fn bias_reply(request_id: u64, diff_on: i32, diff_off: i32) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::BiasesApplied { + applied: SensorBiasOffsetsV1 { diff_on, diff_off }, + readback: SensorBiasReadbackV1 { + current: augur_plugin_api::SensorBiasCodesV1 { + diff_on: (100 + diff_on) as u8, + diff_off: (100 + diff_off) as u8, + fo: 30, + hpf: 40, + refr: 50, + }, + factory_default: augur_plugin_api::SensorBiasCodesV1 { + diff_on: 100, + diff_off: 100, + fo: 30, + hpf: 40, + refr: 50, + }, + }, + readback_age_s: 0.05, + }, + } + } + + fn camera_snapshot() -> CameraConfigurationSnapshotV1 { + CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: augur_plugin_api::CameraBiasOffsetsV1 { + diff_on: 5, + diff_off: -2, + fo: 0, + hpf: 0, + refr: 0, + }, + roi: RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: Vec::new(), + digital_filter: augur_plugin_api::CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 0, + trail_enabled: false, + }, + external_trigger: augur_plugin_api::CameraExternalTriggerV1::default(), + global: augur_plugin_api::CameraGlobalSettingsV1 { + nm_per_pixel: 1_000.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_mib: 512, + preview_interval_ms: 16, + point_cloud_interval_ms: 50, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + } + } + + #[test] + fn a_protocol_bias_point_waits_for_host_readback_and_restores_after_success() { + let folder = temp_folder("protocol-bias"); + let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); + plugin.sensor = Some(fresh_bias_sensor(105, 98)); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = sink.services[0].request_id; + sink.services.clear(); + control_tick( + &mut plugin, + inbox_with(vec![accepted(lease_req)]), + &mut sink, + ); + let retargets: Vec = sink + .services + .iter() + .filter(|request| { + matches!( + modulation_command(request), + Some( + ModulationCommandV1::SetOperatingPoint { .. } + | ModulationCommandV1::SetDriveFrequency { .. } + | ModulationCommandV1::SetOpticalDepth { .. } + ) + ) + }) + .map(|request| request.request_id) + .collect(); + let bias_req = sink + .hosts + .iter() + .find(|request| { + matches!( + request.command, + HostCommand::ApplyBiases { + diff_on: Some(12), + diff_off: Some(-7) + } + ) + }) + .expect("A1 must ask the host to set the point biases") + .request_id; + + control_tick( + &mut plugin, + inbox_with(retargets.into_iter().map(accepted).collect()), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + !plugin.recording.is_active(), + "recording started before the host confirmed its sensor readback" + ); + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![bias_reply(bias_req, 12, -7)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + plugin.recording.is_active(), + "confirmed point did not start" + ); + let metadata = plugin.recording_metadata(); + assert_eq!( + metadata.get("requested_diff_on").map(String::as_str), + Some("12") + ); + assert_eq!( + metadata.get("confirmed_diff_on_code").map(String::as_str), + Some("112") + ); + + plugin.recording = Recording::idle(); + plugin.recording_completed_ok = true; + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::ApplyBiases { + diff_on: Some(5), + diff_off: Some(-2) + }) + )); + + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn camera_bias_control_fails_closed_without_a_sensor_reading() { + let folder = temp_folder("protocol-bias-no-sensor"); + let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); + plugin.sensor = None; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.protocol.is_none()); + assert!(sink.services.is_empty() && sink.hosts.is_empty()); + assert!( + plugin.message.contains("Sensor reading"), + "{}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn bias_only_protocol_fails_closed_when_sensor_recording_is_disabled() { + let folder = temp_folder("protocol-bias-sensor-recording-off"); + let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); + plugin.sensor = Some(fresh_bias_sensor(105, 98)); + plugin.record_sensor_telemetry = false; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.protocol.is_none()); + assert!(sink.services.is_empty() && sink.hosts.is_empty()); + assert!( + plugin.message.contains("Record sensor monitoring"), + "{}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a_rejected_bias_point_aborts_without_recording_and_still_restores() { + let folder = temp_folder("protocol-bias-abort-restore"); + let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); + plugin.sensor = Some(fresh_bias_sensor(105, 98)); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = sink.services[0].request_id; + control_tick( + &mut plugin, + inbox_with(vec![accepted(lease_req)]), + &mut sink, + ); + let bias_req = sink + .hosts + .iter() + .find(|request| matches!(&request.command, HostCommand::ApplyBiases { .. })) + .expect("bias request") + .request_id; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: bias_req, + outcome: HostCommandOutcome::Rejected { + code: "bias_readback_mismatch".into(), + message: "sensor codes do not match".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + + assert!(!plugin.recording.is_active()); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::ApplyBiases { + diff_on: Some(5), + diff_off: Some(-2) + }) + )); + assert!(plugin.protocol.as_ref().is_some_and(|run| { + run.phase == ProtocolPhase::RestoringCamera && run.recorded == 0 + })); + + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a_named_profile_is_applied_before_the_lease_and_restored_on_stop() { + let folder = temp_folder("protocol-profile-restore"); + let (mut plugin, _) = protocol_plugin(&folder, PROFILE_PROTOCOL); + plugin.sensor = Some(fresh_bias_sensor(105, 98)); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(sink.services.is_empty(), "drive moved before camera apply"); + let apply_req = sink.hosts.last().expect("profile apply request"); + assert!(matches!( + &apply_req.command, + HostCommand::ApplyCameraConfiguration { + profile_name: Some(name), + snapshot: None + } if name == "A1 low noise" + )); + let apply_request_id = apply_req.request_id; + let snapshot = camera_snapshot(); + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: apply_request_id, + outcome: HostCommandOutcome::CameraConfigurationApplied { + snapshot: snapshot.clone(), + provenance: CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A1 low noise".into()), + schema_version: 1, + profile_revision: Some(3), + sha256: "ab".repeat(32), + }, + readback: fresh_bias_sensor(105, 98).bias_codes.expect("biases"), + readback_age_s: 0.05, + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert!(sink.services.iter().any(|request| matches!( + modulation_command(request), + Some(ModulationCommandV1::AcquireLease { .. }) + ))); + + plugin.protocol.as_mut().expect("run").stop_requested = true; + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let restore_req = sink.hosts.last().expect("restore request"); + assert!(matches!( + restore_req.command, + HostCommand::RestoreCameraConfiguration + )); + let restore_request_id = restore_req.request_id; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: restore_request_id, + outcome: HostCommandOutcome::CameraConfigurationRestored { + readback: fresh_bias_sensor(105, 98).bias_codes.expect("biases"), + readback_age_s: 0.05, + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert!(plugin.protocol.is_none()); + assert!(plugin.message.contains("restored"), "{}", plugin.message); + + let _ = std::fs::remove_dir_all(&folder); + } + /// The reason a protocol exists rather than three nested button presses: /// every point states its whole operating condition, so all three axes are /// commanded at every point instead of being left wherever the last one @@ -10697,6 +11721,28 @@ bias_refr_code,status,error\n\ ); } + #[test] + fn a1_measurement_limit_uses_the_reported_sample_rate_not_the_drive_limit() { + let mut photodiode = ready_photodiode(); + photodiode.stream.sample_rate_hz = Some(20_000); + let mut plugin = StageAA1Plugin { + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + let blocker = plugin + .photodiode_measurement_blocker(2_000.0) + .expect("20 kSa/s cannot resolve 2 kHz at 16 samples/cycle"); + assert!(blocker.contains("1250"), "{blocker}"); + + plugin + .photodiode + .as_mut() + .expect("photodiode") + .stream + .sample_rate_hz = Some(500_000); + assert!(plugin.photodiode_measurement_blocker(2_000.0).is_none()); + } + /// A connected modulation plugin with no drive applied yet must never be /// reported as disconnected. /// diff --git a/plugins/stage-a-modulation/src/lib.rs b/plugins/stage-a-modulation/src/lib.rs index 634a762..584d6d0 100644 --- a/plugins/stage-a-modulation/src/lib.rs +++ b/plugins/stage-a-modulation/src/lib.rs @@ -47,7 +47,8 @@ use stage_a_plugin_contract::{ PhotodiodeLevelV1, PhotodiodeSummaryV1, RequestOutcomeV1, ResponseCommonV1, RunId, SemanticRevision, ServiceErrorCodeV1, ServiceErrorV1, SynchronizationV1, UnsyncedReasonV1, WaveformV1, CONTRACT_VERSION_V1, CTX_STAGE_A_MODULATION_STATE_V1, - CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, PLUGIN_ID_STAGE_A_MODULATION, PLUGIN_ID_STAGE_A_PHOTODIODE, + CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, DRIVE_FREQUENCY_MAX_MILLIHZ, DRIVE_FREQUENCY_MIN_MILLIHZ, + PLUGIN_ID_STAGE_A_MODULATION, PLUGIN_ID_STAGE_A_PHOTODIODE, SERVICE_STAGE_A_MODULATION_CONTROL_V1, }; @@ -1256,7 +1257,11 @@ impl StageAModulationPlugin { /// (`send_modulation`) and the leased `SetOpticalDepth` service command. fn drive_command(&self) -> Result { let (lo, hi, hold) = self.dac_band()?; - let freq_mhz = (self.frequency_hz.clamp(0.01, 2_000.0) * 1_000.0).round() as i64; + let freq_mhz = (self.frequency_hz.clamp( + DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + DRIVE_FREQUENCY_MAX_MILLIHZ as f64 / 1_000.0, + ) * 1_000.0) + .round() as i64; Ok(match self.mode { Mode::Const => Command::new("MOD") .field("wave", "CONST") @@ -2087,12 +2092,13 @@ impl StageAModulationPlugin { let frequency_hz = *frequency_millihz as f64 / 1_000.0; // The same band `drive_command` clamps to; refuse rather than // silently record a different frequency than the one asked for. - if !(0.01..=2_000.0).contains(&frequency_hz) { + if !stage_a_plugin_contract::drive_frequency_supported(*frequency_millihz) { return Err(service_error( ServiceErrorCodeV1::InvalidCommand, format!( - "frequency {frequency_hz:.3} Hz outside the supported \ - 0.01..=2000 Hz" + "frequency {frequency_hz:.3} Hz outside the supported {:.2}..={} Hz", + DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + DRIVE_FREQUENCY_MAX_MILLIHZ / 1_000 ), false, )); @@ -3150,10 +3156,14 @@ impl Plugin for StageAModulationPlugin { SettingItem { key: "frequency_hz".into(), label: "Frequency".into(), - tooltip: Some("Periodic-waveform frequency, 0.01–2000 Hz".into()), + tooltip: Some(format!( + "Periodic-waveform frequency, {:.2}–{} Hz", + DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + DRIVE_FREQUENCY_MAX_MILLIHZ / 1_000 + )), kind: SettingKind::F64Drag { - min: 0.01, - max: 2_000.0, + min: DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + max: DRIVE_FREQUENCY_MAX_MILLIHZ as f64 / 1_000.0, speed: 1.0, default: self.frequency_hz, }, @@ -3522,7 +3532,10 @@ impl Plugin for StageAModulationPlugin { } "frequency_hz" => { let hz = value.as_f64().ok_or("frequency_hz must be a number")?; - self.frequency_hz = hz.clamp(0.01, 2_000.0); + self.frequency_hz = hz.clamp( + DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + DRIVE_FREQUENCY_MAX_MILLIHZ as f64 / 1_000.0, + ); if self.mode.is_periodic() { self.send_modulation(); } diff --git a/plugins/stage-a-photodiode/src/lib.rs b/plugins/stage-a-photodiode/src/lib.rs index 4f7ca91..840843f 100644 --- a/plugins/stage-a-photodiode/src/lib.rs +++ b/plugins/stage-a-photodiode/src/lib.rs @@ -272,8 +272,44 @@ impl Default for SharedState { } impl SharedState { + /// Samples the ring retains: the operator's cache length, or the whole + /// modulation cycles the optical estimator needs at the period the drive is + /// running — whichever is longer, capped by [`RING_MAX_SAMPLES`]. + /// + /// `a` is only measurable between phase-0 markers, so at low `f` the + /// retained window *is* the gate on whether it can be published at all: two + /// cycles at 0.075 Hz are 26.7 s, which the 20 s default never covers. Left + /// to a setting, that turns into a precondition an operator has to work out + /// per file and set by hand before pressing Start — and an A1 survey whose + /// lowest rung is sub-hertz otherwise records its full duration and only + /// then discovers it has no `a` to write. The markers give the period on the + /// same sample clock the ring is indexed by, so size the ring from them and + /// the precondition disappears. + /// + /// Sizing follows the drive both ways: the ring shrinks back on the next + /// ingest when the frequency goes up, because eviction re-reads the capacity + /// every frame. fn ring_capacity(&self, rate_hz: u32) -> usize { - ((f64::from(rate_hz.max(1)) * self.cache_seconds) as usize).clamp(2, RING_MAX_SAMPLES) + let requested = f64::from(rate_hz.max(1)) * self.cache_seconds; + // One cycle beyond the estimator's window, so a whole window still fits + // once the oldest marker ages out of it. + let needed = self + .contrast_period_samples() + .map_or(0.0, |period| period * (CONTRAST_WINDOW_CYCLES + 1.0)); + (requested.max(needed) as usize).clamp(2, RING_MAX_SAMPLES) + } + + /// The modulation period in samples the ring sizes itself against. + /// + /// The newest marker interval first: it moves to the new period on the first + /// marker after a retarget, where the mean over the retained markers still + /// carries the previous rung and would grow the ring a cycle at a time. It + /// also survives eviction, so a period longer than the ring itself — the + /// case this exists for — is still known. + fn contrast_period_samples(&self) -> Option { + self.marker_period_estimate + .filter(|period| *period > 0.0) + .or_else(|| self.marker_period_samples()) } /// Ingests one `SamplesU16` frame. Any discontinuity — rate change, @@ -3517,16 +3553,44 @@ mod tests { state } + /// The bug an A1 survey paid for a recording at a time: a cache length left + /// at its default is shorter than one cycle of a sub-hertz rung, so the + /// estimator saw no whole cycle, `a` was withheld, and the sidecar was + /// refused *after* the recording had already run its full duration. The ring + /// grows to the drive now, so the same stream publishes an `a`. + #[test] + fn a_cache_shorter_than_the_drive_no_longer_starves_the_estimator() { + let plugin = live_plugin(); + // 0.5 Hz at 20 kSa/s = 40 000 samples per cycle, against a cache set to + // hold 0.6 of one. + let mut state = slow_sine_state(40_000, 24_000, 200_000); + anchor_at(&mut state, 3.0); + let summary = plugin + .optical_summary_result(&state) + .expect("the ring sizes itself to the marker period"); + assert!( + summary.covered_cycles.expect("cycles") >= 2.0, + "window covers {:?} cycles", + summary.covered_cycles + ); + } + #[test] fn a_window_shorter_than_one_cycle_withholds_a_instead_of_under_reporting_it() { // `a` is peak-to-peak. Below one full cycle the robust extrema see an // arc of the sine, so `a` comes out low — and A1's a₀ lock divides by // it, inflating its drive against a bias it cannot see. Fail closed. + // + // A cache too short for the drive is no longer the way to get here — + // the ring follows the period. What is left is a drive whose cycles have + // not gone by yet: the first phase-0 stamp after a retarget or a segment + // restart bounds no whole cycle at all. let plugin = live_plugin(); - // 0.5 Hz at 20 kSa/s = 40 000 samples per cycle; retain 0.6 of one. + // 0.5 Hz at 20 kSa/s = 40 000 samples per cycle, one marker seen. let mut partial = slow_sine_state(40_000, 24_000, 200_000); anchor_at(&mut partial, 3.0); + partial.markers.drain(1..); let error = plugin .optical_summary_result(&partial) .expect_err("a partial cycle must not publish an a"); @@ -3551,8 +3615,15 @@ mod tests { summary.measured_log_contrast ); assert!((summary.measured_frequency_hz.expect("markers") - 0.5).abs() < 0.01); - assert!((summary.window_seconds.expect("window") - 4.0).abs() < 0.01); - assert!(summary.covered_cycles.expect("cycles") >= 1.0); + // The window is whole cycles, and says how many, so a consumer can wait + // it out before trusting a re-read. + let cycles = summary.covered_cycles.expect("cycles"); + assert!(cycles >= 2.0, "covered only {cycles} cycles"); + assert!( + (summary.window_seconds.expect("window") - cycles * 2.0).abs() < 0.01, + "window {:?} is not {cycles} cycles at 0.5 Hz", + summary.window_seconds + ); } #[test] @@ -3968,6 +4039,43 @@ mod tests { assert_eq!(state.markers.len(), 3); } + /// The A1 protocols' 0.075 Hz floor at the bench's 500 kSa/s: two whole + /// cycles are 26.7 s, and the 20 s default cache cannot hold them. The ring + /// has to follow the drive down on its own — a survey that only learns at + /// the end of a 267 s recording that no `a` was retained has already spent + /// the bench time. + #[test] + fn ring_follows_the_drive_down_to_sub_hertz() { + let rate = 500_000_u32; + let mut state = SharedState { + rate_hz: rate, + ..SharedState::default() + }; + assert_eq!( + state.ring_capacity(rate), + 10_000_000, + "with no markers the operator's 20 s default stands" + ); + + // Two markers 0.075 Hz apart are all it takes to know the period. + let period = (f64::from(rate) / 0.075) as u64; + state.push_marker(0); + state.push_marker(period); + + let capacity = state.ring_capacity(rate); + assert_eq!(capacity, RING_MAX_SAMPLES, "sized up to the absolute cap"); + assert!( + capacity as u64 >= 2 * period, + "two cycles at 0.075 Hz need {} samples, ring holds {capacity}", + 2 * period + ); + + // Back up at 8.7 Hz the ring returns to the operator's cache length. + let fast = (f64::from(rate) / 8.7) as u64; + state.push_marker(period + fast); + assert_eq!(state.ring_capacity(rate), 10_000_000); + } + #[test] fn ring_is_bounded_by_duration() { let mut state = SharedState::default(); diff --git a/plugins/stage-a-a1/src/csv.rs b/stage-a-plugin-contract/src/csv.rs similarity index 100% rename from plugins/stage-a-a1/src/csv.rs rename to stage-a-plugin-contract/src/csv.rs diff --git a/stage-a-plugin-contract/src/lib.rs b/stage-a-plugin-contract/src/lib.rs index d3bc593..312b649 100644 --- a/stage-a-plugin-contract/src/lib.rs +++ b/stage-a-plugin-contract/src/lib.rs @@ -1,18 +1,45 @@ //! Versioned, serde-only messages shared by Stage-A experiment workflows and -//! the two persistent Teensy device-owner plugins. +//! the two persistent Teensy device-owner plugins, plus the small pure helpers +//! more than one Stage-A workflow needs ([`telemetry`], [`csv`]). //! -//! This crate contains semantic control-plane types only. It intentionally -//! contains no Augur ABI types, serial transports, filesystem access, raw ADC -//! arrays, or experiment state machines. +//! This crate intentionally contains no Augur ABI types, serial transports, +//! filesystem access, raw ADC arrays, or experiment state machines. Every +//! experiment plugin exports `augur_plugin_vtable`, so shared code cannot live +//! in one of them and be linked by another — it lives here, in a plain library +//! that exports no vtable at all (ADR 031). #![forbid(unsafe_code)] +pub mod csv; +pub mod telemetry; + use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; pub const CONTRACT_VERSION_V1: u16 = 1; +/// Firmware-qualified periodic-drive range. These values mirror +/// `stage-a-controller/include/board_config.h`; all Rust-side UI, service and +/// protocol validation uses this one definition rather than duplicating the +/// literals. The connected firmware remains authoritative and rejects outside +/// this range as well. +pub const DRIVE_FREQUENCY_MIN_MILLIHZ: u64 = 10; +pub const DRIVE_FREQUENCY_MAX_MILLIHZ: u64 = 2_000_000; +pub const DRIVE_DAC_UPDATE_RATE_HZ: u32 = 40_000; +/// Minimum sample density for an A1 photodiode waveform measurement. Nyquist +/// alone only proves non-aliasing; 16 samples/cycle is the project's minimum +/// shape-resolution acceptance threshold. +pub const A1_MIN_SAMPLES_PER_CYCLE: u32 = 16; + +pub fn drive_frequency_supported(frequency_millihz: u64) -> bool { + (DRIVE_FREQUENCY_MIN_MILLIHZ..=DRIVE_FREQUENCY_MAX_MILLIHZ).contains(&frequency_millihz) +} + +pub fn a1_measurement_frequency_limit_hz(sample_rate_hz: u32) -> f64 { + f64::from(sample_rate_hz) / f64::from(A1_MIN_SAMPLES_PER_CYCLE) +} + pub const PLUGIN_ID_STAGE_A_MODULATION: &str = "stage-a.modulation"; pub const PLUGIN_ID_STAGE_A_PHOTODIODE: &str = "stage-a.photodiode"; pub const SERVICE_STAGE_A_MODULATION_CONTROL_V1: &str = "stage_a.modulation.control.v1"; @@ -764,6 +791,16 @@ mod tests { ); } + #[test] + fn firmware_drive_bounds_and_a1_measurement_bounds_are_distinct() { + assert!(drive_frequency_supported(DRIVE_FREQUENCY_MIN_MILLIHZ)); + assert!(drive_frequency_supported(DRIVE_FREQUENCY_MAX_MILLIHZ)); + assert!(!drive_frequency_supported(DRIVE_FREQUENCY_MIN_MILLIHZ - 1)); + assert!(!drive_frequency_supported(DRIVE_FREQUENCY_MAX_MILLIHZ + 1)); + assert_eq!(a1_measurement_frequency_limit_hz(20_000), 1_250.0); + assert_eq!(a1_measurement_frequency_limit_hz(500_000), 31_250.0); + } + #[test] fn modulation_request_round_trips_with_semantic_discriminants() { let mut request = ModulationRequestV1::new( diff --git a/plugins/stage-a-a1/src/sensor.rs b/stage-a-plugin-contract/src/telemetry.rs similarity index 92% rename from plugins/stage-a-a1/src/sensor.rs rename to stage-a-plugin-contract/src/telemetry.rs index d426e42..5ca738d 100644 --- a/plugins/stage-a-a1/src/sensor.rs +++ b/stage-a-plugin-contract/src/telemetry.rs @@ -5,10 +5,10 @@ //! `.sensor-monitoring.csv` next to the RAW. Two things are wrong //! with keeping that file as it is: //! -//! 1. **It stays behind.** A1 gathers the camera RAW, its bias sidecar, the -//! photodiode PDQ and the description file into one measurement folder -//! under one name; the telemetry did not travel with them, so the bench -//! conditions of a run were separated from the run at the first `mv`. +//! 1. **It stays behind.** A workflow gathers the camera RAW, its bias sidecar +//! and the description file into one measurement folder under one name; the +//! telemetry did not travel with them, so the bench conditions of a run were +//! separated from the run at the first `mv`. //! //! 2. **It is a wide table of mostly-empty cells.** The channels are polled on //! different schedules — the die temperature drifts over minutes, the pixel @@ -25,8 +25,13 @@ use std::collections::BTreeMap; use std::fmt::Write as _; -/// Schema tag written into every readout file. -pub const SCHEMA: &str = "stage-a.a1.sensor.v1"; +/// Schema tag for A1's readout files. +pub const SCHEMA_A1: &str = "stage-a.a1.sensor.v1"; + +/// Schema tag for A4's readout files. The layout is identical; the tag names +/// the workflow that produced the file so a folder of mixed measurements is +/// still self-describing. +pub const SCHEMA_A4: &str = "stage-a.a4.sensor.v1"; /// One channel's samples, in acquisition order. #[derive(Debug, Default, Clone, PartialEq)] @@ -83,10 +88,10 @@ impl SensorReadout { /// each: these files are read by eye as often as by script, and a pretty /// printer puts one number per line — thousands of lines for what is /// conceptually one row. - pub fn to_json(&self, measurement_id: &str, recording_stem: &str) -> String { + pub fn to_json(&self, schema: &str, measurement_id: &str, recording_stem: &str) -> String { let mut out = String::with_capacity(1_024 + self.polls * 24); out.push_str("{\n"); - let _ = writeln!(out, " \"schema\": \"{SCHEMA}\","); + let _ = writeln!(out, " \"schema\": {},", json_string(schema)); let _ = writeln!( out, " \"measurement_id\": {},", @@ -315,7 +320,7 @@ bias_refr_code,status,error"; assert!(!name.starts_with("bias"), "bias channel survived: {name}"); } let parsed: serde_json::Value = - serde_json::from_str(&readout.to_json("m", "s")).expect("valid JSON"); + serde_json::from_str(&readout.to_json(SCHEMA_A1, "m", "s")).expect("valid JSON"); let channels = parsed["channels"].as_object().expect("channels object"); assert!( channels.keys().all(|name| !name.starts_with("bias")), @@ -386,10 +391,10 @@ bias_refr_code,status,error"; "1,1,full,1000,1200,0,0,140.0,41.5,12.7,,,,,,ok,", "1,2,fast,2000,2200,0,0,,,12.8,,,,,,ok,", ]); - let json = parse_csv(&text).to_json("meas-1", "meas-1_20260731T120000Z"); + let json = parse_csv(&text).to_json(SCHEMA_A1, "meas-1", "meas-1_20260731T120000Z"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); - assert_eq!(parsed["schema"], SCHEMA); + assert_eq!(parsed["schema"], SCHEMA_A1); assert_eq!(parsed["measurement_id"], "meas-1"); assert_eq!(parsed["polls"], 2); assert_eq!(parsed["channels"]["pixel_dead_time_us"]["value"][1], 12.8); @@ -411,7 +416,7 @@ bias_refr_code,status,error"; let readout = parse_csv(&text); assert_eq!(readout.faults[0].message, "a, b, \"c\""); let parsed: serde_json::Value = - serde_json::from_str(&readout.to_json("m", "s")).expect("valid JSON"); + serde_json::from_str(&readout.to_json(SCHEMA_A1, "m", "s")).expect("valid JSON"); assert_eq!(parsed["faults"][0]["message"], "a, b, \"c\""); } } From bbeccbc64558b8a4b1650f6d1e7887fb30566a7d Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Wed, 12 Aug 2026 17:29:33 +0200 Subject: [PATCH 02/11] =?UTF-8?q?fix(stage-a-a1):=20=F0=9F=90=9B=20use=20g?= =?UTF-8?q?eneric=20camera=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1-camera-configurations-and-bias-points.md | 30 +- docs/architecture.md | 6 + docs/features/stage-a-a1.md | 10 +- plugins/stage-a-a1/README.md | 10 +- plugins/stage-a-a1/plugin.toml | 1 - plugins/stage-a-a1/src/runtime.rs | 483 +++++++++++++----- 6 files changed, 387 insertions(+), 153 deletions(-) diff --git a/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md index 6ba5e64..d6c31f0 100644 --- a/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md +++ b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md @@ -2,7 +2,7 @@ - **Status:** Accepted - **Date:** 2026-08-12 -- **Relates to:** ADR 027, ADR 035, `augur-rs` ADR 036 and ADR 037 +- **Relates to:** ADR 027 and `augur-rs` ADR 037 ## Context @@ -23,23 +23,31 @@ Per-point threshold offsets use only the host's canonical names `diff_on` and columns per row. The values are relative offsets around the sensor's factory trim. No `bias_on` or `bias_off` aliases are introduced. -A1 routes full configuration selection through -`ApplyCameraConfiguration` and point changes through the existing narrow -`ApplyBiases` command. The host applies them immediately. A1 waits for the host -reply containing a sensor read taken after the change. It never waits for an -extra user Apply action and never records an unconfirmed point. +A1 routes both the initial selection and every point change through the host's +generic `ApplyCameraConfiguration` command. For a point change, A1 clones the +last host-confirmed complete snapshot and changes only its requested +`diff_on`/`diff_off` fields. Thus A1's own protocol surface stays narrow while +the host remains independent of A1 and has no bias-specific command. The host +applies the complete snapshot immediately. A1 waits for the reply containing a +sensor read taken after the change. It never waits for an extra user Apply +action and never records an unconfirmed point. Bias control requires a fresh sensor-monitoring context before the drive moves. -Missing or disabled sensor reading, an out-of-range offset, a rejected apply, -or a mismatched/missing readback fails closed. Drive retarget replies and the -bias confirmation must both arrive before settle and recording. +A1, not the host, verifies that the confirmed snapshot enables sensor telemetry +and disables STC and Trail. Missing or disabled sensor reading, an out-of-range +offset, a rejected apply, or a mismatched/missing readback fails closed. Drive +retarget replies and the configuration confirmation must both arrive before +settle and recording. The host-start metadata and A1 sidecar store requested offsets, confirmed offsets, absolute current and factory codes, readback age, the immutable camera snapshot, and profile provenance. The host restores a full configuration -session; a bias-only protocol restores the offsets measured before the run. +session; a bias-only protocol starts the session from the currently applied +complete configuration and restores that same configuration after the run. Normal completion, Stop, and abort use the same restore path and do not report -success until the restore reply arrives. +success until the restore reply arrives. A1 retries a rejected or timed-out +restore up to three times and reports an explicit error if none is confirmed; +it never labels an unconfirmed restore as successful. ## Compatibility diff --git a/docs/architecture.md b/docs/architecture.md index 0925914..8b28c44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,12 @@ For the full host-side contract, use the upstream authoring guide: - `augur-plugins` owns the runtime plugin implementations and the template crate used to start new plugins. - Shared domain payloads should live in companion crates when multiple plugins need the same types. +The host remains a standalone general-purpose recorder when every plugin is +removed. Host contracts therefore expose only generic operations and never +name plugin IDs, workflows, or scientific gates. A plugin may declare and use a +generic host capability, but its field restrictions and measurement-validity +rules stay in this repository. + ## Runtime Packaging Each installed runtime plugin ships as: diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index dc3983a..ae7c274 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -59,7 +59,8 @@ A1 has two jobs on the Stage-A bench, both deliberately thin: A1 owns no hardware and never opens the Teensy or camera directly. The optical drive remains owned by the modulation plugin and camera settings remain owned -by the host; A1 can retarget them only through their allowlisted control paths. +by the host; A1 retargets them only through declared, generic control +capabilities. ## The recording workflow @@ -388,6 +389,13 @@ resolved versioned snapshot and profile provenance, the point's requested sent in recorder metadata, so the RAW, PDQ, and A1 sidecar identify one immutable configuration even if the saved profile later changes. +A1 applies the initial configuration and each point through the same generic +complete-snapshot host command. For a bias point it clones the last confirmed +snapshot and changes only `diff_on`/`diff_off`; no A1- or bias-specific command +exists in the recorder. A1 checks its own scientific requirements (sensor +telemetry on, STC and Trail off) and starts recording automatically after the +host returns a fresh matching sensor readback. + For manual recordings A1 never drives the Teensy: set the drive (high `a` for the pilot, `a≈0` for the background) in the modulation plugin, then press the matching button — the recording captures whatever `a` is currently set. diff --git a/plugins/stage-a-a1/README.md b/plugins/stage-a-a1/README.md index aaeb5da..6bbbc2b 100644 --- a/plugins/stage-a-a1/README.md +++ b/plugins/stage-a-a1/README.md @@ -210,7 +210,15 @@ one named profile or one complete versioned inline snapshot. Plugin camera changes are applied immediately by the host and shown as applied settings; no extra user Apply click is required. A1 records only after a fresh sensor readback confirms the codes and restores the pre-run settings on success, -Stop, or abort. Missing/disabled Sensor reading fails closed. +Stop, or abort. A rejected or timed-out restore is retried up to three times and +is never reported as successful without confirmation. Missing/disabled Sensor +reading fails closed. + +The host command always carries a complete camera snapshot. For a point, A1 +clones the last confirmed snapshot and changes only `diff_on`/`diff_off`, so the +remaining biases, ROI, mask, filters, trigger, and global settings stay explicit +and unchanged. A1 itself rejects confirmed configurations with sensor telemetry +off or STC/Trail on; these experiment rules are not built into the recorder. The current firmware-qualified drive range is 0.01 Hz to 2 kHz. A1 also requires at least 16 photodiode samples per cycle: 1.25 kHz at 20 kSa/s and diff --git a/plugins/stage-a-a1/plugin.toml b/plugins/stage-a-a1/plugin.toml index 1d62766..f1a4021 100644 --- a/plugins/stage-a-a1/plugin.toml +++ b/plugins/stage-a-a1/plugin.toml @@ -9,7 +9,6 @@ min_augur_version = "1.0.0" host_commands = [ "start_recording", "stop_recording", - "apply_biases", "apply_camera_configuration", "restore_camera_configuration", ] diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index 60ed1ea..4ab3207 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -43,16 +43,17 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use augur_plugin_api::{ - export_plugin, CameraConfigurationProvenanceV1, CameraConfigurationSnapshotV1, - EventStoreHandle, FfiCdEvent, GlobalSettings, HostCommand, HostCommandOutcome, - HostCommandReply, HostCommandRequest, HostContext, HostDatasetDescriptor, HostDatasetKind, - HostOutput, HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, - PathDialogKind, Plugin, PluginCapabilities, PluginControlContext, PluginControlInbox, - PluginDiscontinuity, PluginFrame, PluginInput, PluginRuntimeRole, PluginServiceOutcome, - PluginServiceReply, PluginServiceRequest, RoiV1, SensorBiasOffsetsV1, SensorBiasReadbackV1, - SensorMonitoringV1, Series1dLine, Series1dPoint, Series1dV1, SettingItem, SettingKind, - SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, TableColumnValues, - TableDatasetV1, TableSchema, TableValueType, CTX_GLOBAL_SETTINGS, CTX_SENSOR_MONITORING, + export_plugin, CameraBiasOffsetsV1, CameraConfigurationProvenanceV1, + CameraConfigurationSnapshotV1, CameraConfigurationSourceV1, EventStoreHandle, FfiCdEvent, + GlobalSettings, HostCommand, HostCommandOutcome, HostCommandReply, HostCommandRequest, + HostContext, HostDatasetDescriptor, HostDatasetKind, HostOutput, HostViewDescriptor, + HostViewKind, HostViewPlacement, HostViewRegistry, PathDialogKind, Plugin, PluginCapabilities, + PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, PluginInput, + PluginRuntimeRole, PluginServiceOutcome, PluginServiceReply, PluginServiceRequest, RoiV1, + SensorBiasReadbackV1, SensorMonitoringV1, Series1dLine, Series1dPoint, Series1dV1, SettingItem, + SettingKind, SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, + TableColumnValues, TableDatasetV1, TableSchema, TableValueType, CTX_GLOBAL_SETTINGS, + CTX_SENSOR_MONITORING, }; use serde::Serialize; use serde_json::{json, Value}; @@ -97,6 +98,7 @@ const DEFAULT_WINDOW_FLOOR: f64 = 0.10; const DEFAULT_ANALYSIS_WINDOW_MS: i64 = 2_000; /// Give up waiting for a control-plane reply after this many milliseconds. const REPLY_TIMEOUT_MS: u64 = 15_000; +const CAMERA_RESTORE_MAX_ATTEMPTS: u8 = 3; /// Upper bound on retained phase-0 markers in the no-EventStore fallback path. const MAX_MARKERS: usize = 65_536; /// Give up waiting for the photodiode-measured `a` to reach a sweep target @@ -818,7 +820,7 @@ impl FreqSweep { /// Where a protocol run is within its per-point cycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProtocolPhase { - /// The host is resolving/applying the named profile or inline snapshot. + /// The host is resolving/applying and confirming a complete configuration. ApplyingCamera, /// AcquireLease sent to the modulation owner; waiting for the grant. AcquiringLease, @@ -830,7 +832,7 @@ enum ProtocolPhase { /// The recording coordinator owns this phase. Recording, /// All recording work is complete; the host is restoring the pre-run - /// configuration (or A1 is restoring the original point biases). + /// configuration. RestoringCamera, } @@ -852,13 +854,15 @@ struct ProtocolRun { camera_apply_req: Option, camera_session_active: bool, camera_snapshot: Option, + camera_profile_provenance: Option, camera_provenance: Option, camera_confirmation: Option<(SensorBiasReadbackV1, f64)>, bias_req: Option, - bias_confirmation: Option<(SensorBiasOffsetsV1, SensorBiasReadbackV1, f64)>, - original_biases: Option, - biases_changed: bool, + bias_confirmation: Option<(CameraBiasOffsetsV1, SensorBiasReadbackV1, f64)>, restore_req: Option, + restore_attempts: u8, + restore_confirmed: bool, + restore_error: Option, finish_message: Option, /// Request ids of the retargets in flight for the current point. A point /// only proceeds once this is empty: the three axes are applied @@ -889,6 +893,18 @@ impl ProtocolRun { } } +fn a1_camera_configuration_refusal( + snapshot: &CameraConfigurationSnapshotV1, +) -> Option<&'static str> { + if !snapshot.global.record_sensor_telemetry { + return Some("Record sensor monitoring is disabled"); + } + if snapshot.digital_filter.stc_enabled || snapshot.digital_filter.trail_enabled { + return Some("STC and Trail must be disabled for an event-count protocol"); + } + None +} + /// On-disk form of the per-frequency lock table. #[derive(Debug, Clone, Default, Serialize, serde::Deserialize)] struct A0LockTable { @@ -2170,6 +2186,9 @@ impl StageAA1Plugin { "camera_configuration_schema_version".into(), provenance.schema_version.to_string(), ); + } + if let Some(provenance) = run.camera_profile_provenance.as_ref() { + meta.insert("camera_profile_sha256".into(), provenance.sha256.clone()); if let Some(name) = provenance.profile_name.as_ref() { meta.insert("camera_profile_name".into(), name.clone()); } @@ -4609,26 +4628,18 @@ impl StageAA1Plugin { .into(); return; } - let original_biases = if controls_camera { + if controls_camera { let Some(sensor) = self.sensor.filter(|sensor| sensor.age_s <= 2.0) else { self.message = "Protocol refused: camera bias control requires a fresh Sensor reading".into(); return; }; - let Some(readback) = sensor.bias_codes else { + if sensor.bias_codes.is_none() { self.message = "Protocol refused: the Sensor reading contains no bias readback".into(); return; - }; - Some(SensorBiasOffsetsV1 { - diff_on: i32::from(readback.current.diff_on) - - i32::from(readback.factory_default.diff_on), - diff_off: i32::from(readback.current.diff_off) - - i32::from(readback.factory_default.diff_off), - }) - } else { - None - }; + } + } let now_ms = now_unix_ms(); let lease_id = LeaseId::new(format!( @@ -4636,7 +4647,7 @@ impl StageAA1Plugin { format_compact_utc(now_ms / 1_000) )); let camera_selection = plan.camera.clone(); - let phase = if camera_selection.is_some() { + let phase = if controls_camera { ProtocolPhase::ApplyingCamera } else { ProtocolPhase::AcquiringLease @@ -4660,13 +4671,15 @@ impl StageAA1Plugin { camera_apply_req: None, camera_session_active: false, camera_snapshot: None, + camera_profile_provenance: None, camera_provenance: None, camera_confirmation: None, bias_req: None, bias_confirmation: None, - original_biases, - biases_changed: false, restore_req: None, + restore_attempts: 0, + restore_confirmed: false, + restore_error: None, finish_message: None, pending_reqs: Vec::new(), settle_until_ms: 0, @@ -4677,25 +4690,20 @@ impl StageAA1Plugin { stop_requested: false, skip_reason: None, }); - if let Some(selection) = camera_selection { + if controls_camera { let request_id = self.next_request_id(); - let command = match selection { - protocol::CameraSelection::NamedProfile(profile_name) => { - HostCommand::ApplyCameraConfiguration { - profile_name: Some(profile_name), - snapshot: None, - } + let configuration = match camera_selection { + Some(protocol::CameraSelection::NamedProfile(name)) => { + CameraConfigurationSourceV1::NamedProfile { name } } - protocol::CameraSelection::Snapshot(snapshot) => { - HostCommand::ApplyCameraConfiguration { - profile_name: None, - snapshot: Some(snapshot), - } + Some(protocol::CameraSelection::Snapshot(snapshot)) => { + CameraConfigurationSourceV1::Snapshot { snapshot } } + None => CameraConfigurationSourceV1::Current, }; context.request_host(&HostCommandRequest { request_id, - command, + command: HostCommand::ApplyCameraConfiguration { configuration }, }); if let Some(run) = self.protocol.as_mut() { run.camera_apply_req = Some(request_id); @@ -4737,28 +4745,12 @@ impl StageAA1Plugin { return; } - let restore_command = if run.camera_session_active { - Some(HostCommand::RestoreCameraConfiguration) - } else if run.biases_changed { - run.original_biases.map(|biases| HostCommand::ApplyBiases { - diff_on: Some(biases.diff_on), - diff_off: Some(biases.diff_off), - }) - } else { - None - }; - if let Some(command) = restore_command { - let request_id = self.next_request_id(); - context.request_host(&HostCommandRequest { - request_id, - command, - }); + if run.camera_session_active { if let Some(run) = self.protocol.as_mut() { run.phase = ProtocolPhase::RestoringCamera; - run.restore_req = Some(request_id); run.finish_message = Some(message); - run.last_activity_ms = now_unix_ms(); } + self.request_protocol_camera_restore(context); self.message = "Protocol stopped recording; restoring the pre-run camera settings…".into(); return; @@ -4767,6 +4759,19 @@ impl StageAA1Plugin { self.complete_protocol(context, message); } + fn request_protocol_camera_restore(&mut self, context: &mut impl RecordingControl) { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::RestoreCameraConfiguration, + }); + if let Some(run) = self.protocol.as_mut() { + run.restore_req = Some(request_id); + run.restore_attempts = run.restore_attempts.saturating_add(1); + run.last_activity_ms = now_unix_ms(); + } + } + /// Release the modulation lease after camera restoration has resolved. fn complete_protocol(&mut self, context: &mut impl RecordingControl, message: String) { if let Some(run) = self.protocol.take() { @@ -4793,8 +4798,10 @@ impl StageAA1Plugin { return; }; let lease_id = run.lease_id.clone(); - let (index, total) = (run.index, run.plan.points.len()); - let ttl_ms = Self::protocol_lease_ttl_ms(&run.plan, index); + let index = run.index; + let total = run.plan.points.len(); + let ttl_ms = Self::protocol_lease_ttl_ms(&run.plan, run.index); + let camera_snapshot = run.camera_snapshot.clone(); let renew = self.modulation_request(ModulationCommandV1::RenewLease { ttl_ms }, &lease_id); context.request_service(&renew); @@ -4819,13 +4826,26 @@ impl StageAA1Plugin { context.request_service(&request); } - let bias_request = if point.diff_on.is_some() || point.diff_off.is_some() { + let point_changes_biases = point.diff_on.is_some() || point.diff_off.is_some(); + let point_snapshot = point_changes_biases + .then_some(camera_snapshot) + .flatten() + .map(|mut snapshot| { + if let Some(diff_on) = point.diff_on { + snapshot.biases.diff_on = diff_on; + } + if let Some(diff_off) = point.diff_off { + snapshot.biases.diff_off = diff_off; + } + snapshot + }); + let missing_camera_snapshot = point_changes_biases && point_snapshot.is_none(); + let bias_request = if let Some(snapshot) = point_snapshot { let request_id = self.next_request_id(); context.request_host(&HostCommandRequest { request_id, - command: HostCommand::ApplyBiases { - diff_on: point.diff_on, - diff_off: point.diff_off, + command: HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, }, }); Some(request_id) @@ -4848,8 +4868,8 @@ impl StageAA1Plugin { run.pending_reqs = pending; run.bias_req = bias_request; run.bias_confirmation = None; - run.biases_changed |= bias_request.is_some(); - run.skip_reason = None; + run.skip_reason = missing_camera_snapshot + .then(|| "the host did not return a complete camera snapshot".into()); run.last_activity_ms = now_ms; } self.message = format!( @@ -5100,29 +5120,51 @@ impl StageAA1Plugin { ProtocolPhase::RestoringCamera => { if restore_pending { if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { - let message = self - .protocol - .as_ref() - .and_then(|run| run.finish_message.clone()) - .unwrap_or_else(|| "Protocol ended".into()); - self.complete_protocol( - context, - format!( - "{message} — ERROR: pre-run camera settings were not confirmed restored" - ), - ); + if let Some(run) = self.protocol.as_mut() { + run.restore_req = None; + run.restore_error = Some("host reply timed out".into()); + run.last_activity_ms = now_ms; + } } return; } - let message = self + let (restore_confirmed, restore_attempts, restore_error) = self .protocol - .as_mut() - .and_then(|run| run.finish_message.take()) - .unwrap_or_else(|| "Protocol ended; pre-run camera settings restored".into()); - self.complete_protocol( - context, - format!("{message} — pre-run camera settings restored"), - ); + .as_ref() + .map(|run| { + ( + run.restore_confirmed, + run.restore_attempts, + run.restore_error.clone(), + ) + }) + .unwrap_or_default(); + if restore_confirmed { + let message = self + .protocol + .as_mut() + .and_then(|run| run.finish_message.take()) + .unwrap_or_else(|| "Protocol ended".into()); + self.complete_protocol( + context, + format!("{message} — pre-run camera settings restored"), + ); + } else if restore_attempts < CAMERA_RESTORE_MAX_ATTEMPTS { + self.request_protocol_camera_restore(context); + } else { + let message = self + .protocol + .as_mut() + .and_then(|run| run.finish_message.take()) + .unwrap_or_else(|| "Protocol ended".into()); + self.complete_protocol( + context, + format!( + "{message} — ERROR: pre-run camera settings were not confirmed restored after {restore_attempts} attempts ({})", + restore_error.unwrap_or_else(|| "unknown restore failure".into()) + ), + ); + } } } } @@ -5495,16 +5537,31 @@ impl StageAA1Plugin { run.camera_apply_req = None; run.camera_session_active = true; run.camera_snapshot = Some(snapshot.clone()); + run.camera_profile_provenance = provenance + .profile_name + .is_some() + .then(|| provenance.clone()); run.camera_provenance = Some(provenance.clone()); run.camera_confirmation = Some((*readback, *readback_age_s)); - run.phase = ProtocolPhase::AcquiringLease; - run.lease_req = 0; + if let Some(reason) = a1_camera_configuration_refusal(snapshot) { + run.stop_requested = true; + run.finish_message = Some(format!( + "Protocol aborted: applied camera configuration is incompatible: {reason}" + )); + } else { + run.phase = ProtocolPhase::AcquiringLease; + run.lease_req = 0; + } run.last_activity_ms = now_ms; } } HostCommandOutcome::Rejected { code, message } => { if let Some(run) = self.protocol.as_mut() { run.camera_apply_req = None; + // A host rejection is terminal only after any + // required rollback has completed. A missing reply + // remains the ambiguous case handled by timeout. + run.camera_session_active = false; run.stop_requested = true; run.finish_message = Some(format!( "Protocol aborted: camera configuration rejected ({code}): {message}" @@ -5529,14 +5586,23 @@ impl StageAA1Plugin { if bias_req == Some(reply.request_id) { let now_ms = now_unix_ms(); match &reply.outcome { - HostCommandOutcome::BiasesApplied { - applied, + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, readback, readback_age_s, } => { if let Some(run) = self.protocol.as_mut() { run.bias_req = None; - run.bias_confirmation = Some((*applied, *readback, *readback_age_s)); + run.camera_snapshot = Some(snapshot.clone()); + run.camera_provenance = Some(provenance.clone()); + run.bias_confirmation = + Some((snapshot.biases, *readback, *readback_age_s)); + if let Some(reason) = a1_camera_configuration_refusal(snapshot) { + run.skip_reason = Some(format!( + "applied camera configuration is incompatible: {reason}" + )); + } run.last_activity_ms = now_ms; } } @@ -5565,25 +5631,22 @@ impl StageAA1Plugin { let restored = matches!( reply.outcome, HostCommandOutcome::CameraConfigurationRestored { .. } - | HostCommandOutcome::BiasesApplied { .. } ); if let Some(run) = self.protocol.as_mut() { run.restore_req = None; - run.camera_session_active = false; - run.biases_changed = false; run.last_activity_ms = now_ms; - if !restored { + if restored { + run.camera_session_active = false; + run.restore_confirmed = true; + run.restore_error = None; + } else { let detail = match &reply.outcome { HostCommandOutcome::Rejected { code, message } => { format!("restore rejected ({code}): {message}") } _ => "host returned an invalid restore confirmation".into(), }; - let message = run - .finish_message - .take() - .unwrap_or_else(|| "Protocol ended".into()); - run.finish_message = Some(format!("{message} — ERROR: {detail}")); + run.restore_error = Some(detail); } } return; @@ -5992,12 +6055,7 @@ impl StageAA1Plugin { .or_else(|| { run.camera_confirmation.map(|(readback, age_s)| { let offsets = - run.camera_snapshot - .as_ref() - .map(|snapshot| SensorBiasOffsetsV1 { - diff_on: snapshot.biases.diff_on, - diff_off: snapshot.biases.diff_off, - }); + run.camera_snapshot.as_ref().map(|snapshot| snapshot.biases); (offsets, Some(readback), Some(age_s)) }) }) @@ -6005,6 +6063,7 @@ impl StageAA1Plugin { Some(CameraControlSidecar { snapshot: run.camera_snapshot.clone(), provenance: run.camera_provenance.clone(), + profile_provenance: run.camera_profile_provenance.clone(), requested_diff_on: point.and_then(|point| point.diff_on), requested_diff_off: point.and_then(|point| point.diff_off), confirmed_offsets, @@ -6265,11 +6324,13 @@ struct CameraControlSidecar { #[serde(skip_serializing_if = "Option::is_none")] provenance: Option, #[serde(skip_serializing_if = "Option::is_none")] + profile_provenance: Option, + #[serde(skip_serializing_if = "Option::is_none")] requested_diff_on: Option, #[serde(skip_serializing_if = "Option::is_none")] requested_diff_off: Option, #[serde(skip_serializing_if = "Option::is_none")] - confirmed_offsets: Option, + confirmed_offsets: Option, #[serde(skip_serializing_if = "Option::is_none")] confirmed_readback: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -10766,11 +10827,18 @@ depth_a = 0.7 } } - fn bias_reply(request_id: u64, diff_on: i32, diff_off: i32) -> HostCommandReply { + fn configuration_reply( + request_id: u64, + snapshot: CameraConfigurationSnapshotV1, + provenance: CameraConfigurationProvenanceV1, + ) -> HostCommandReply { + let diff_on = snapshot.biases.diff_on; + let diff_off = snapshot.biases.diff_off; HostCommandReply { request_id, - outcome: HostCommandOutcome::BiasesApplied { - applied: SensorBiasOffsetsV1 { diff_on, diff_off }, + outcome: HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, readback: SensorBiasReadbackV1 { current: augur_plugin_api::SensorBiasCodesV1 { diff_on: (100 + diff_on) as u8, @@ -10792,6 +10860,23 @@ depth_a = 0.7 } } + fn current_configuration_reply( + request_id: u64, + snapshot: CameraConfigurationSnapshotV1, + ) -> HostCommandReply { + configuration_reply( + request_id, + snapshot, + CameraConfigurationProvenanceV1 { + source: "current_configuration".into(), + profile_name: None, + schema_version: 1, + profile_revision: None, + sha256: "cd".repeat(32), + }, + ) + } + fn camera_snapshot() -> CameraConfigurationSnapshotV1 { CameraConfigurationSnapshotV1 { schema_version: 1, @@ -10838,8 +10923,27 @@ depth_a = 0.7 let mut sink = ControlSink::default(); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); - let lease_req = sink.services[0].request_id; - sink.services.clear(); + let session_req = sink.hosts.last().expect("current configuration request"); + assert!(matches!( + &session_req.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current + } + )); + let session_request_id = session_req.request_id; + let initial_snapshot = camera_snapshot(); + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![current_configuration_reply( + session_request_id, + initial_snapshot.clone(), + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let lease_req = sink.services.last().expect("lease request").request_id; control_tick( &mut plugin, inbox_with(vec![accepted(lease_req)]), @@ -10860,20 +10964,29 @@ depth_a = 0.7 }) .map(|request| request.request_id) .collect(); - let bias_req = sink + let point_request = sink .hosts .iter() - .find(|request| { - matches!( - request.command, - HostCommand::ApplyBiases { - diff_on: Some(12), - diff_off: Some(-7) - } - ) + .rev() + .find(|request| match &request.command { + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + } => snapshot.biases.diff_on == 12 && snapshot.biases.diff_off == -7, + _ => false, }) - .expect("A1 must ask the host to set the point biases") - .request_id; + .expect("A1 must apply a complete snapshot for the point biases"); + let HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + } = &point_request.command + else { + unreachable!("matched above") + }; + assert_eq!(snapshot.biases.fo, initial_snapshot.biases.fo); + assert_eq!(snapshot.biases.hpf, initial_snapshot.biases.hpf); + assert_eq!(snapshot.biases.refr, initial_snapshot.biases.refr); + assert_eq!(snapshot.roi, initial_snapshot.roi); + assert_eq!(snapshot.digital_filter, initial_snapshot.digital_filter); + let bias_req = point_request.request_id; control_tick( &mut plugin, @@ -10886,10 +10999,23 @@ depth_a = 0.7 "recording started before the host confirmed its sensor readback" ); + let mut point_snapshot = initial_snapshot; + point_snapshot.biases.diff_on = 12; + point_snapshot.biases.diff_off = -7; control_tick( &mut plugin, PluginControlInbox { - host_replies: vec![bias_reply(bias_req, 12, -7)], + host_replies: vec![configuration_reply( + bias_req, + point_snapshot, + CameraConfigurationProvenanceV1 { + source: "inline_snapshot".into(), + profile_name: None, + schema_version: 1, + profile_revision: None, + sha256: "ef".repeat(32), + }, + )], ..PluginControlInbox::default() }, &mut sink, @@ -10914,10 +11040,7 @@ depth_a = 0.7 control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); assert!(matches!( sink.hosts.last().map(|request| &request.command), - Some(HostCommand::ApplyBiases { - diff_on: Some(5), - diff_off: Some(-2) - }) + Some(HostCommand::RestoreCameraConfiguration) )); let _ = std::fs::remove_dir_all(&folder); @@ -10970,7 +11093,16 @@ depth_a = 0.7 let mut sink = ControlSink::default(); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); - let lease_req = sink.services[0].request_id; + let session_req = sink.hosts.last().expect("session request").request_id; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![current_configuration_reply(session_req, camera_snapshot())], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let lease_req = sink.services.last().expect("lease request").request_id; control_tick( &mut plugin, inbox_with(vec![accepted(lease_req)]), @@ -10979,8 +11111,16 @@ depth_a = 0.7 let bias_req = sink .hosts .iter() - .find(|request| matches!(&request.command, HostCommand::ApplyBiases { .. })) - .expect("bias request") + .rev() + .find(|request| { + matches!( + &request.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { .. } + } + ) + }) + .expect("point configuration request") .request_id; control_tick( &mut plugin, @@ -11000,10 +11140,7 @@ depth_a = 0.7 assert!(!plugin.recording.is_active()); assert!(matches!( sink.hosts.last().map(|request| &request.command), - Some(HostCommand::ApplyBiases { - diff_on: Some(5), - diff_off: Some(-2) - }) + Some(HostCommand::RestoreCameraConfiguration) )); assert!(plugin.protocol.as_ref().is_some_and(|run| { run.phase == ProtocolPhase::RestoringCamera && run.recorded == 0 @@ -11025,8 +11162,7 @@ depth_a = 0.7 assert!(matches!( &apply_req.command, HostCommand::ApplyCameraConfiguration { - profile_name: Some(name), - snapshot: None + configuration: CameraConfigurationSourceV1::NamedProfile { name } } if name == "A1 low noise" )); let apply_request_id = apply_req.request_id; @@ -11086,6 +11222,75 @@ depth_a = 0.7 let _ = std::fs::remove_dir_all(&folder); } + #[test] + fn rejected_camera_restore_retries_and_never_reports_success() { + let folder = temp_folder("protocol-profile-restore-rejected"); + let (mut plugin, _) = protocol_plugin(&folder, PROFILE_PROTOCOL); + plugin.sensor = Some(fresh_bias_sensor(105, 98)); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let apply_request_id = sink.hosts.last().expect("profile apply request").request_id; + let snapshot = camera_snapshot(); + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![configuration_reply( + apply_request_id, + snapshot, + CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A1 low noise".into()), + schema_version: 1, + profile_revision: Some(3), + sha256: "ab".repeat(32), + }, + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + + plugin.protocol.as_mut().expect("run").stop_requested = true; + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + for attempt in 1..=CAMERA_RESTORE_MAX_ATTEMPTS { + let restore_request_id = sink.hosts.last().expect("restore request").request_id; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: restore_request_id, + outcome: HostCommandOutcome::Rejected { + code: "camera_configuration_restore_failed".into(), + message: "device refused restore".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + if attempt < CAMERA_RESTORE_MAX_ATTEMPTS { + assert!(plugin.protocol.is_some(), "restore stopped before retry"); + assert_ne!( + sink.hosts.last().expect("retry request").request_id, + restore_request_id + ); + } + } + + assert!(plugin.protocol.is_none()); + assert!(plugin.message.contains("ERROR"), "{}", plugin.message); + assert!( + !plugin.message.ends_with("pre-run camera settings restored"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(&folder); + } + /// The reason a protocol exists rather than three nested button presses: /// every point states its whole operating condition, so all three axes are /// commanded at every point instead of being left wherever the last one From b98f5e0c6628cf60bb535a37d2c75376b1f026af Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 13:14:06 +0200 Subject: [PATCH 03/11] =?UTF-8?q?refactor(stage-a-a1):=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20trust=20generic=20host=20confirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1-camera-configurations-and-bias-points.md | 16 +-- docs/features/stage-a-a1.md | 9 +- plugins/stage-a-a1/README.md | 7 +- plugins/stage-a-a1/src/protocol.rs | 35 +++--- plugins/stage-a-a1/src/runtime.rs | 101 +++++++++++------- 5 files changed, 95 insertions(+), 73 deletions(-) diff --git a/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md index d6c31f0..511c651 100644 --- a/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md +++ b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md @@ -32,12 +32,16 @@ applies the complete snapshot immediately. A1 waits for the reply containing a sensor read taken after the change. It never waits for an extra user Apply action and never records an unconfirmed point. -Bias control requires a fresh sensor-monitoring context before the drive moves. -A1, not the host, verifies that the confirmed snapshot enables sensor telemetry -and disables STC and Trail. Missing or disabled sensor reading, an out-of-range -offset, a rejected apply, or a mismatched/missing readback fails closed. Drive -retarget replies and the configuration confirmation must both arrive before -settle and recording. +Bias control requires the host's successful apply reply, including a fresh +generation-bound sensor readback, before recording may start. It does not depend +on the previous UI context: a selected profile may enable sensor monitoring as +part of the same atomic apply. The initial series configuration is confirmed +before A1 acquires the drive lease. A1, not the host, verifies afterwards that +the confirmed snapshot enables sensor telemetry and disables STC and Trail. +Sensor-specific bias ranges remain owned by the active camera backend. A +rejected apply or a mismatched/missing readback fails closed. At each point, +drive-retarget replies and the configuration confirmation must both arrive +before settle and recording. The host-start metadata and A1 sidecar store requested offsets, confirmed offsets, absolute current and factory codes, readback age, the immutable camera diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index ae7c274..37e085e 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -281,9 +281,12 @@ snapshot, profile revision, and hash travel with every recording. Camera values are applied immediately through the host camera-control path; the operator does not click Apply again. A point waits for a fresh sensor readback -that confirms the requested codes. Sensor reading missing or disabled refuses -the run. Completion, Stop, and abort restore the settings that were active -before the protocol. +that confirms the requested codes. The confirmed host snapshot is authoritative: +a profile may enable Sensor reading in the same apply, without waiting for an +operator action. Missing readback or a confirmed snapshot with Sensor reading +disabled refuses the run. The active camera backend validates its own bias +ranges. Completion, Stop, and abort restore the settings that were active before +the protocol. ### Frequency generation and measurement limit (ADR 036) diff --git a/plugins/stage-a-a1/README.md b/plugins/stage-a-a1/README.md index 6bbbc2b..92b47fa 100644 --- a/plugins/stage-a-a1/README.md +++ b/plugins/stage-a-a1/README.md @@ -211,8 +211,11 @@ Plugin camera changes are applied immediately by the host and shown as applied settings; no extra user Apply click is required. A1 records only after a fresh sensor readback confirms the codes and restores the pre-run settings on success, Stop, or abort. A rejected or timed-out restore is retried up to three times and -is never reported as successful without confirmation. Missing/disabled Sensor -reading fails closed. +is never reported as successful without confirmation. A named profile may +enable Sensor reading in that same apply; A1 uses the confirmed host reply, not +the previous UI state. Missing readback or a confirmed snapshot with Sensor +reading disabled fails closed. Sensor-specific bias ranges stay in the camera +backend. The host command always carries a complete camera snapshot. For a point, A1 clones the last confirmed snapshot and changes only `diff_on`/`diff_off`, so the diff --git a/plugins/stage-a-a1/src/protocol.rs b/plugins/stage-a-a1/src/protocol.rs index 1fa6f6f..0b0346d 100644 --- a/plugins/stage-a-a1/src/protocol.rs +++ b/plugins/stage-a-a1/src/protocol.rs @@ -408,8 +408,6 @@ pub fn parse(text: &str) -> Result { } let diff_on = block.diff_on.or(doc.defaults.diff_on); let diff_off = block.diff_off.or(doc.defaults.diff_off); - validate_bias("diff_on", diff_on)?; - validate_bias("diff_off", diff_off)?; let mean_u = block .mean_u @@ -633,12 +631,12 @@ pub fn parse_csv(text: &str) -> Result { if raw.is_empty() { return Ok(None); } - let value = raw.parse::().map_err(|_| ProtocolError::Invalid { - what: format!("line {line_no}: {name}"), - detail: format!("'{raw}' is not a signed integer offset"), - })?; - validate_bias(&format!("line {line_no}: {name}"), Some(value))?; - Ok(Some(value)) + raw.parse::() + .map(Some) + .map_err(|_| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail: format!("'{raw}' is not a signed integer offset"), + }) }; let diff_on = parse_bias("diff_on")?; let diff_off = parse_bias("diff_off")?; @@ -683,16 +681,6 @@ pub fn parse_csv(text: &str) -> Result { }) } -fn validate_bias(what: &str, value: Option) -> Result<(), ProtocolError> { - if let Some(value) = value.filter(|value| !(-85..=140).contains(value)) { - return Err(ProtocolError::Invalid { - what: what.to_owned(), - detail: format!("{value} is outside the supported -85..=140 offset range"), - }); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -842,19 +830,20 @@ diff_on = 20 } #[test] - fn out_of_range_bias_offsets_are_refused_before_the_run() { - let error = parse( + fn sensor_specific_bias_ranges_are_left_to_the_host_backend() { + let protocol = parse( r#" [[block]] mean_u = 0.5 frequency_hz = 10.0 depth_a = 0.5 diff_on = 141 +diff_off = 191 "#, ) - .expect_err("invalid bias"); - assert!(error.to_string().contains("diff_on"), "{error}"); - assert!(error.to_string().contains("-85..=140"), "{error}"); + .expect("the active camera backend owns its supported ranges"); + assert_eq!(protocol.points[0].diff_on, Some(141)); + assert_eq!(protocol.points[0].diff_off, Some(191)); } #[test] diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index 4ab3207..e55a22f 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -4622,24 +4622,6 @@ impl StageAA1Plugin { .points .iter() .any(|point| point.diff_on.is_some() || point.diff_off.is_some()); - if controls_camera && plan.camera.is_none() && !self.record_sensor_telemetry { - self.message = - "Protocol refused: enable Record sensor monitoring before a bias-controlled run" - .into(); - return; - } - if controls_camera { - let Some(sensor) = self.sensor.filter(|sensor| sensor.age_s <= 2.0) else { - self.message = - "Protocol refused: camera bias control requires a fresh Sensor reading".into(); - return; - }; - if sensor.bias_codes.is_none() { - self.message = - "Protocol refused: the Sensor reading contains no bias readback".into(); - return; - } - } let now_ms = now_unix_ms(); let lease_id = LeaseId::new(format!( @@ -6407,10 +6389,10 @@ struct FilesSidecar { /// was recording. /// /// Absent whenever the host wrote no telemetry companion. Usually that is - /// the host's own **Record sensor monitoring** switch being off, not a - /// camera without a monitoring block: the switch governs the whole file - /// and A1 cannot ask for it. The single-point readings in `[sensor]` come - /// from the context bus and are there either way. + /// the confirmed camera configuration's **Record sensor monitoring** switch + /// being off, not a camera without a monitoring block. A protocol profile + /// can enable the switch through the generic host apply. The single-point + /// readings in `[sensor]` come from the context bus and are there either way. #[serde(skip_serializing_if = "Option::is_none")] sensor_readout: Option, } @@ -11047,41 +11029,81 @@ depth_a = 0.7 } #[test] - fn camera_bias_control_fails_closed_without_a_sensor_reading() { + fn camera_bias_control_relies_on_the_hosts_confirmed_apply() { let folder = temp_folder("protocol-bias-no-sensor"); let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); plugin.sensor = None; let mut sink = ControlSink::default(); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); - - assert!(plugin.protocol.is_none()); - assert!(sink.services.is_empty() && sink.hosts.is_empty()); + let apply = sink.hosts.last().expect("host apply request"); + assert!(matches!( + apply.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current + } + )); + let apply_request_id = apply.request_id; assert!( - plugin.message.contains("Sensor reading"), - "{}", - plugin.message + sink.services.is_empty(), + "drive moved before host confirmation" ); + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: apply_request_id, + outcome: HostCommandOutcome::Rejected { + code: "camera_configuration_readback_timeout".into(), + message: "fresh sensor readback was unavailable".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.protocol.is_none()); + assert!(sink.services.is_empty()); + assert!(!plugin.recording.is_active()); + assert!(plugin.message.contains("readback"), "{}", plugin.message); let _ = std::fs::remove_dir_all(&folder); } #[test] - fn bias_only_protocol_fails_closed_when_sensor_recording_is_disabled() { + fn a_confirmed_configuration_with_sensor_recording_disabled_is_restored() { let folder = temp_folder("protocol-bias-sensor-recording-off"); let (mut plugin, _) = protocol_plugin(&folder, BIAS_PROTOCOL); - plugin.sensor = Some(fresh_bias_sensor(105, 98)); + plugin.sensor = None; plugin.record_sensor_telemetry = false; let mut sink = ControlSink::default(); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); - - assert!(plugin.protocol.is_none()); - assert!(sink.services.is_empty() && sink.hosts.is_empty()); - assert!( - plugin.message.contains("Record sensor monitoring"), - "{}", - plugin.message + let apply_request_id = sink.hosts.last().expect("host apply request").request_id; + let mut snapshot = camera_snapshot(); + snapshot.global.record_sensor_telemetry = false; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![current_configuration_reply(apply_request_id, snapshot)], + ..PluginControlInbox::default() + }, + &mut sink, ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(!plugin.recording.is_active()); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration) + )); + assert!(plugin + .protocol + .as_ref() + .and_then(|run| run.finish_message.as_deref()) + .is_some_and(|message| message.contains("Record sensor monitoring"))); let _ = std::fs::remove_dir_all(&folder); } @@ -11153,7 +11175,8 @@ depth_a = 0.7 fn a_named_profile_is_applied_before_the_lease_and_restored_on_stop() { let folder = temp_folder("protocol-profile-restore"); let (mut plugin, _) = protocol_plugin(&folder, PROFILE_PROTOCOL); - plugin.sensor = Some(fresh_bias_sensor(105, 98)); + plugin.sensor = None; + plugin.record_sensor_telemetry = false; let mut sink = ControlSink::default(); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); From 35a4bc6eee41170642cb120a6d6f9d14d3eb9d10 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:30:51 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(stage-a):=20=E2=9C=A8=20carry=20A2?= =?UTF-8?q?=20timing=20and=20photodiode=20geometry=20on=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract gains the A2 step-latency acquisition configuration and its `PrepareA2` command, so the modulation owner can put the firmware into mode A2 without A2 reaching past it. The photodiode side gains an explicit detector placement. Until now the rejected-port complement was the only geometry the estimator knew, and it is the one geometry in which a DC dark level cancels. A direct sample of the camera or emission path does not cancel it, so those placements carry a captured lamp-off dark reference and refuse `a` until one exists. The splitter fraction travels as provenance only: a constant fraction cancels out of a log contrast and must not be used to rescale it. --- stage-a-io/src/estimator.rs | 8 +++ stage-a-io/src/transport.rs | 15 ++++- stage-a-io/src/wire.rs | 2 + stage-a-plugin-contract/src/lib.rs | 100 ++++++++++++++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/stage-a-io/src/estimator.rs b/stage-a-io/src/estimator.rs index 78fab3b..4657c0d 100644 --- a/stage-a-io/src/estimator.rs +++ b/stage-a-io/src/estimator.rs @@ -97,6 +97,9 @@ pub enum EstimateError { /// The rejected-complement geometry has no explicitly confirmed, /// traceable total-power anchor. MissingTotalPowerAnchor, + /// A direct camera/emission-path measurement has no explicitly captured + /// or manually supplied blocked-light reference. + MissingDirectDarkReference, /// No marker-bounded window containing at least two complete modulation /// cycles fits inside the retained sample budget. IncompleteModulationCycles { @@ -141,6 +144,11 @@ impl std::fmt::Display for EstimateError { moment; it learns I_tot from the brightest reading it sees, which the Pockels \ transfer sweep produces exactly", ), + Self::MissingDirectDarkReference => f.write_str( + "no lamp-off dark reference has been captured for the direct photodiode path — \ + physically block the light and press Capture lamp-off dark, or explicitly enter \ + a manual value", + ), Self::IncompleteModulationCycles { marker_count, max_samples, diff --git a/stage-a-io/src/transport.rs b/stage-a-io/src/transport.rs index 5e1b820..e21832b 100644 --- a/stage-a-io/src/transport.rs +++ b/stage-a-io/src/transport.rs @@ -258,7 +258,10 @@ mod tests { #[test] fn unix_keeps_the_linux_cdc_acm_node() { let ports = vec![port("/dev/ttyACM0", true), port("/dev/ttyS0", false)]; - assert_eq!(names(narrow_to_candidates(ports, false)), vec!["/dev/ttyACM0"]); + assert_eq!( + names(narrow_to_candidates(ports, false)), + vec!["/dev/ttyACM0"] + ); } #[test] @@ -266,7 +269,10 @@ mod tests { // The bug: COMn matches neither `cu.usbmodem` nor `ttyACM`, so the // Teensy's two ports were filtered out before any probe could run. let ports = vec![port("COM3", true), port("COM4", true)]; - assert_eq!(names(narrow_to_candidates(ports, true)), vec!["COM3", "COM4"]); + assert_eq!( + names(narrow_to_candidates(ports, true)), + vec!["COM3", "COM4"] + ); } #[test] @@ -278,7 +284,10 @@ mod tests { #[test] fn windows_probes_everything_when_the_os_classifies_nothing() { let ports = vec![port("COM1", false), port("COM3", false)]; - assert_eq!(names(narrow_to_candidates(ports, true)), vec!["COM1", "COM3"]); + assert_eq!( + names(narrow_to_candidates(ports, true)), + vec!["COM1", "COM3"] + ); } #[test] diff --git a/stage-a-io/src/wire.rs b/stage-a-io/src/wire.rs index bb90b5a..dd7e827 100644 --- a/stage-a-io/src/wire.rs +++ b/stage-a-io/src/wire.rs @@ -218,6 +218,8 @@ pub struct MarkerPayload { /// `source` value the firmware stamps on a modulation phase-0 marker. pub const MARKER_SOURCE_PHASE0: u8 = 1; +/// `source` value the firmware stamps on an A2 optical comparator crossing. +pub const MARKER_SOURCE_COMPARATOR: u8 = 2; impl MarkerPayload { pub fn encode(&self) -> Vec { diff --git a/stage-a-plugin-contract/src/lib.rs b/stage-a-plugin-contract/src/lib.rs index 312b649..03e766f 100644 --- a/stage-a-plugin-contract/src/lib.rs +++ b/stage-a-plugin-contract/src/lib.rs @@ -289,6 +289,28 @@ pub struct A1AcquisitionConfigV1 { pub optical_lut_id: Option, } +/// Complete, firmware-level configuration for one A2 step-latency point. +/// +/// The optical coordinates are calibrated lobe coordinates, not physical +/// photon flux. `min_half_us` is a precomputed safety floor from the qualified +/// A1/A5 timing bounds; the firmware enforces it and never guesses it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct A2AcquisitionConfigV1 { + pub mean_u_milli: u32, + pub depth_a_milli: u32, + pub frequency_millihz: u64, + pub min_half_us: u32, + pub v_null_dac: u16, + pub v_peak_dac: u16, + pub comparator_threshold_dac: u16, + pub comparator_hysteresis: u8, + pub comparator_invert: bool, + pub sample_rate_hz: u32, + pub block_samples: u32, + pub emit_raw_samples: bool, + pub emit_summary: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ModulationCommandV1 { @@ -357,6 +379,12 @@ pub enum ModulationCommandV1 { PrepareA1 { configuration: A1AcquisitionConfigV1, }, + /// Stop the current controller acquisition, enter firmware mode A2, + /// configure the optical log-square and its 50 % comparator, and require + /// the board to echo `trigger_source=comparator` with the comparator armed. + PrepareA2 { + configuration: A2AcquisitionConfigV1, + }, StartAcquisition, StopAcquisition { reason: String, @@ -385,6 +413,8 @@ pub struct ModulationTargetV1 { pub revision: SemanticRevision, pub waveform: Option, pub a1_configuration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub a2_configuration: Option, pub acquisition_running: bool, pub board_dac_code: Option, pub firmware_configuration_revision: Option, @@ -624,10 +654,57 @@ pub struct PhotodiodeResponseV1 { pub struct PhotodiodeCalibrationV1 { pub adc_calibration_id: String, pub dark_id: String, - pub anchor_id: String, + /// Full-extinction reference used only for rejected-port complement + /// geometry. Direct camera/emission-path measurements have no such anchor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor_id: Option, pub dark_volts: f64, + /// Traceable direct-path dark reference. `None` for the historical + /// rejected-port geometry, where the same-detector complement cancels the + /// dark offset. Additive for older V1 readers and writers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dark_reference: Option, /// Named full-extinction anchor after dark subtraction. - pub total_power_volts: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_power_volts: Option, +} + +/// How a direct-path blocked-light reference entered the session state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PhotodiodeDarkSourceV1 { + /// Captured from the owner's settled raw detector window while the + /// operator had physically blocked the light. + MeasuredLampOff, + /// Entered through the numeric setting rather than measured by the owner. + Manual, +} + +/// Provenance for the direct camera/emission-path dark subtraction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeDarkReferenceV1 { + pub dark_id: String, + pub source: PhotodiodeDarkSourceV1, + pub dark_volts: f64, + pub captured_at_unix_ms: u64, + /// Age when this enclosing summary or artifact was produced. + pub age_s: f64, +} + +/// Physical location of the one Stage-A photodiode. +/// +/// `RejectedPort` is the historical PBS-complement geometry and therefore the +/// default when an older owner did not publish this additive field. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PhotodiodePlacementV1 { + #[default] + RejectedPort, + /// Direct sample of the path sent towards the camera, before a microscope + /// emission chain has been established. + CameraPath, + /// Direct sample of fluorescence after the emission filter. + EmissionPath, } /// Bounded optical result for one named run. It contains no raw or decimated @@ -636,6 +713,14 @@ pub struct PhotodiodeCalibrationV1 { pub struct PhotodiodeOpticalSummaryV1 { pub run_id: RunId, pub calibration: PhotodiodeCalibrationV1, + /// Detector geometry used to derive `measured_log_contrast`. + #[serde(default)] + pub placement: PhotodiodePlacementV1, + /// Fraction of the local beam sent to the photodiode, e.g. `0.5` for a + /// 50:50 splitter. It is provenance; a constant fraction cancels from log + /// contrast and is not used as a scale correction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub splitter_fraction: Option, pub measured_log_contrast: f64, pub log_contrast_stddev: Option, pub excitation_min_volts: f64, @@ -718,6 +803,16 @@ pub struct PhotodiodeSummaryV1 { pub active_recording: Option, pub last_finalized_recording: Option, pub optical_summary: Option, + /// Current detector placement, available even while no optical window has + /// passed the estimator gates. + #[serde(default)] + pub placement: PhotodiodePlacementV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub splitter_fraction: Option, + /// Current direct-path dark provenance, even while another optical gate + /// withholds `optical_summary`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dark_reference: Option, /// Why `optical_summary` is absent, in the owner's own words. /// /// A withheld `a` is a fail-closed refusal, not missing data, and every @@ -858,6 +953,7 @@ mod tests { revision: SemanticRevision(5), waveform: Some(WaveformV1::Constant { level_dac: 900 }), a1_configuration: None, + a2_configuration: None, acquisition_running: false, board_dac_code: None, firmware_configuration_revision: None, From 4f3299f6b9e7706127629520078f6a145a550bb7 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:01 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(stage-a-modulation):=20=E2=9C=A8=20p?= =?UTF-8?q?repare=20the=20firmware=20for=20A2=20step=20latency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modulation owner is the only crate that speaks to the command port, so putting the board into A2 mode belongs here rather than in A2 itself. The shipped A1 laboratory protocols are parsed as fixtures and checked against the coupled calibrated-drive limits, so a protocol that asks for a `ū`/`a` pair the drive cannot reach is caught here instead of at the bench. --- plugins/stage-a-modulation/Cargo.toml | 3 + plugins/stage-a-modulation/src/lib.rs | 155 +++++++- .../src/protocol_validation_tests.rs | 349 ++++++++++++++++++ 3 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 plugins/stage-a-modulation/src/protocol_validation_tests.rs diff --git a/plugins/stage-a-modulation/Cargo.toml b/plugins/stage-a-modulation/Cargo.toml index 57207f9..f843774 100644 --- a/plugins/stage-a-modulation/Cargo.toml +++ b/plugins/stage-a-modulation/Cargo.toml @@ -15,3 +15,6 @@ serde_json.workspace = true stage-a-io = { path = "../../stage-a-io" } stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } toml = "0.8" + +[dev-dependencies] +augur-plugin-stage-a-a1 = { path = "../stage-a-a1" } diff --git a/plugins/stage-a-modulation/src/lib.rs b/plugins/stage-a-modulation/src/lib.rs index 584d6d0..cde22a9 100644 --- a/plugins/stage-a-modulation/src/lib.rs +++ b/plugins/stage-a-modulation/src/lib.rs @@ -21,6 +21,8 @@ //! modulation off — drag the power slider to 0 to drive 0 V. mod calibration; +#[cfg(test)] +mod protocol_validation_tests; mod waveform; use std::collections::{BTreeMap, VecDeque}; @@ -40,16 +42,17 @@ use augur_plugin_api::{ }; use serde_json::{json, Value}; use stage_a_io::{Command, DeviceEvent, MockController, StageAClient, Transport}; +use stage_a_plugin_contract::drive_frequency_supported; use stage_a_plugin_contract::{ - A1AcquisitionConfigV1, ClientId, ConnectionStateV1, ControllerStateV1, FreshnessV1, LeaseId, - LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, ModulationResponseV1, - ModulationStateV1, ModulationTargetV1, OpticalDriveStateV1, OpticalTargetV1, OwnerInstanceId, - PhotodiodeLevelV1, PhotodiodeSummaryV1, RequestOutcomeV1, ResponseCommonV1, RunId, - SemanticRevision, ServiceErrorCodeV1, ServiceErrorV1, SynchronizationV1, UnsyncedReasonV1, - WaveformV1, CONTRACT_VERSION_V1, CTX_STAGE_A_MODULATION_STATE_V1, - CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, DRIVE_FREQUENCY_MAX_MILLIHZ, DRIVE_FREQUENCY_MIN_MILLIHZ, - PLUGIN_ID_STAGE_A_MODULATION, PLUGIN_ID_STAGE_A_PHOTODIODE, - SERVICE_STAGE_A_MODULATION_CONTROL_V1, + A1AcquisitionConfigV1, A2AcquisitionConfigV1, ClientId, ConnectionStateV1, ControllerStateV1, + FreshnessV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, + ModulationResponseV1, ModulationStateV1, ModulationTargetV1, OpticalDriveStateV1, + OpticalTargetV1, OwnerInstanceId, PhotodiodeLevelV1, PhotodiodeSummaryV1, RequestOutcomeV1, + ResponseCommonV1, RunId, SemanticRevision, ServiceErrorCodeV1, ServiceErrorV1, + SynchronizationV1, UnsyncedReasonV1, WaveformV1, CONTRACT_VERSION_V1, + CTX_STAGE_A_MODULATION_STATE_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + DRIVE_FREQUENCY_MAX_MILLIHZ, DRIVE_FREQUENCY_MIN_MILLIHZ, PLUGIN_ID_STAGE_A_MODULATION, + PLUGIN_ID_STAGE_A_PHOTODIODE, SERVICE_STAGE_A_MODULATION_CONTROL_V1, }; const STATUS_DATASET_ID: &str = "stage-a-modulation.status"; @@ -328,6 +331,7 @@ impl DeviceState { revision: SemanticRevision(0), waveform: Some(waveform), a1_configuration: None, + a2_configuration: None, acquisition_running: self.controller_state == ControllerStateV1::Running, board_dac_code: self.board_code.and_then(|code| u16::try_from(code).ok()), firmware_configuration_revision: None, @@ -541,6 +545,18 @@ fn execute_operation( } } + if error.is_none() && operation.purpose == "PREPARE_A2" { + let prepared = merged.get("trigger_source").map(String::as_str) == Some("comparator") + && merged.get("cmp_armed").map(String::as_str) == Some("1") + && merged.get("mod_wave").map(String::as_str) == Some("LOG_SQUARE"); + if !prepared { + error = Some( + "firmware did not confirm trigger_source=comparator, cmp_armed=1 and mod_wave=LOG_SQUARE" + .into(), + ); + } + } + let mut state = shared.state.lock().expect("device state lock"); let succeeded = error.is_none(); if let Some(message) = error { @@ -1798,6 +1814,7 @@ impl StageAModulationPlugin { revision, waveform: None, a1_configuration: None, + a2_configuration: None, acquisition_running: false, board_dac_code: None, firmware_configuration_revision: None, @@ -2215,6 +2232,27 @@ impl StageAModulationPlugin { false, ) } + ModulationCommandV1::PrepareA2 { configuration } => { + self.require_lease(request)?; + validate_a2_configuration(configuration)?; + let revision = self.requested_revision(request)?; + let mut target = self.base_target(revision); + target.a1_configuration = None; + target.a2_configuration = Some(configuration.clone()); + target.acquisition_running = false; + self.queue_service_operation( + request, + target, + vec![ + Command::new("STOP").field("reason", "prepare_a2"), + a2_config_command(configuration), + a2_comparator_command(configuration), + a2_log_square_command(configuration), + ], + "PREPARE_A2", + false, + ) + } ModulationCommandV1::StartAcquisition => { self.require_lease(request)?; let revision = self.requested_revision(request)?; @@ -2772,6 +2810,69 @@ fn a1_config_command(configuration: &A1AcquisitionConfigV1) -> Command { .field("summary", u8::from(configuration.emit_summary)) } +fn validate_a2_configuration(configuration: &A2AcquisitionConfigV1) -> Result<(), ServiceErrorV1> { + let invalid = |message: &str| service_error(ServiceErrorCodeV1::InvalidCommand, message, false); + if !(1..=1_000).contains(&configuration.mean_u_milli) { + return Err(invalid("A2 mean_u_milli must be in 1..=1000")); + } + if configuration.depth_a_milli == 0 { + return Err(invalid("A2 depth_a_milli must be positive")); + } + if !drive_frequency_supported(configuration.frequency_millihz) { + return Err(invalid("A2 frequency is outside the firmware drive range")); + } + let half_us = 500_000_000_u64 / configuration.frequency_millihz; + if half_us < u64::from(configuration.min_half_us) { + return Err(invalid("A2 half-period is below min_half_us")); + } + if configuration.v_peak_dac <= configuration.v_null_dac { + return Err(invalid("A2 v_peak_dac must be greater than v_null_dac")); + } + if !(1..=4_095).contains(&configuration.comparator_threshold_dac) { + return Err(invalid("A2 comparator threshold must be in 1..=4095")); + } + if configuration.comparator_hysteresis > 3 { + return Err(invalid("A2 comparator hysteresis must be in 0..=3")); + } + if !(100..=500_000).contains(&configuration.sample_rate_hz) { + return Err(invalid("A2 sample_rate_hz must be in 100..=500000")); + } + if configuration.block_samples == 0 { + return Err(invalid("A2 block_samples must be positive")); + } + if !configuration.emit_raw_samples && !configuration.emit_summary { + return Err(invalid("A2 must enable raw samples or summaries")); + } + Ok(()) +} + +fn a2_config_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("CONFIG") + .field("mode", "A2") + .field("rate_hz", configuration.sample_rate_hz) + .field("block_samples", configuration.block_samples) + .field("raw", u8::from(configuration.emit_raw_samples)) + .field("summary", u8::from(configuration.emit_summary)) +} + +fn a2_comparator_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("CMP") + .field("thr", configuration.comparator_threshold_dac) + .field("hyst", configuration.comparator_hysteresis) + .field("invert", u8::from(configuration.comparator_invert)) +} + +fn a2_log_square_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("MOD") + .field("wave", "LOG_SQUARE") + .field("a_milli", configuration.depth_a_milli) + .field("u_k_milli", configuration.mean_u_milli) + .field("v_null", configuration.v_null_dac) + .field("v_pi", configuration.v_peak_dac - configuration.v_null_dac) + .field("freq_mhz", configuration.frequency_millihz) + .field("min_half_us", configuration.min_half_us) +} + fn accepted_service_reply( request: &PluginServiceRequest, response: &ModulationResponseV1, @@ -5457,4 +5558,40 @@ mod tests { assert!(plugin.link.is_none()); assert!(plugin.lease.is_none()); } + + #[test] + fn a2_commands_match_the_firmware_grammar_and_use_lobe_span() { + let config = A2AcquisitionConfigV1 { + mean_u_milli: 300, + depth_a_milli: 450, + frequency_millihz: 500, + min_half_us: 100_000, + v_null_dac: 100, + v_peak_dac: 1_000, + comparator_threshold_dac: 1_500, + comparator_hysteresis: 1, + comparator_invert: true, + sample_rate_hz: 500_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + }; + validate_a2_configuration(&config).unwrap(); + assert_eq!( + String::from_utf8(a2_config_command(&config).encode(1).unwrap()).unwrap(), + "@1 CONFIG mode=A2 rate_hz=500000 block_samples=256 raw=1 summary=1\n" + ); + assert_eq!( + String::from_utf8(a2_comparator_command(&config).encode(2).unwrap()).unwrap(), + "@2 CMP thr=1500 hyst=1 invert=1\n" + ); + let drive = String::from_utf8(a2_log_square_command(&config).encode(3).unwrap()).unwrap(); + assert!(drive.contains("wave=LOG_SQUARE")); + assert!(drive.contains("v_null=100 v_pi=900"), "{drive}"); + assert!(drive.contains("min_half_us=100000"), "{drive}"); + + let mut unsafe_threshold = config; + unsafe_threshold.comparator_threshold_dac = 0; + assert!(validate_a2_configuration(&unsafe_threshold).is_err()); + } } diff --git a/plugins/stage-a-modulation/src/protocol_validation_tests.rs b/plugins/stage-a-modulation/src/protocol_validation_tests.rs new file mode 100644 index 0000000..3c5a89a --- /dev/null +++ b/plugins/stage-a-modulation/src/protocol_validation_tests.rs @@ -0,0 +1,349 @@ +//! End-to-end static validation of the A1 bench protocols against the same +//! coupled optical-drive calculations the modulation owner uses at runtime. + +use std::path::Path; + +use augur_plugin_api::{ + ExecutionContext, ExecutionMode, Plugin, PluginRuntimeRole, PluginServiceOutcome, + PluginServiceRequest, +}; +use augur_plugin_stage_a_a1::protocol::{parse_csv, ProtocolPoint}; +use serde_json::Value; +use stage_a_plugin_contract::{ + ClientId, LeaseId, ModulationCommandV1, ModulationRequestV1, RunId, + PLUGIN_ID_STAGE_A_MODULATION, SERVICE_STAGE_A_MODULATION_CONTROL_V1, +}; + +use super::waveform::{ + log_sine_geometric_pedestal, LobeInversion, OpticalDrive, OpticalTarget, PeakLaw, + DAC_FULL_SCALE, DEPTH_A_MAX, DEPTH_A_MIN, MEAN_U_MIN, +}; +use super::{now_unix_ms, DriveMethod, Mode, StageAModulationPlugin}; + +// A conservative policy of the qualified A1 protocol set, in addition to the +// modulation owner's measured-lobe and DAC ceilings. +const PEAK_U_GUARD: f64 = 0.90; + +struct Fixture { + name: &'static str, + csv: &'static str, + expected_points: usize, +} + +fn fixtures() -> [Fixture; 5] { + [ + Fixture { + name: "a1_triage_90min.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_triage_90min.csv"), + expected_points: 40, + }, + Fixture { + name: "a1_stufe1_bode_dc.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe1_bode_dc.csv"), + expected_points: 73, + }, + Fixture { + name: "a1_stufe2_bode_u010.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u010.csv"), + expected_points: 47, + }, + Fixture { + name: "a1_stufe2_bode_u045.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u045.csv"), + expected_points: 47, + }, + Fixture { + name: "a1_stufe2_flussleiter.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_flussleiter.csv"), + expected_points: 231, + }, + ] +} + +/// The applied bench calibration and DAC ceiling used to qualify the files. +/// Endpoint rounding mirrors `apply_calibration`; resolving the pair mirrors +/// the modulation owner's `lobe_inversion` path. +fn qualified_lobe() -> (LobeInversion, f64) { + let calibration: Value = + serde_json::from_str(include_str!("../testdata/pockels-20260730-083123.json")) + .expect("recorded Pockels calibration JSON"); + let number = |key: &str| { + calibration[key] + .as_f64() + .unwrap_or_else(|| panic!("calibration has no numeric {key}")) + }; + let v_null = number("v_null_dac"); + let v_peak = v_null + number("v_pi_dac"); + let inversion = + LobeInversion::resolve(v_null.round(), v_peak.round(), f64::from(DAC_FULL_SCALE)) + .expect("recorded calibration resolves to a drivable lobe") + .inversion; + (inversion, number("max_level")) +} + +fn milli(value: f64) -> f64 { + (value * 1_000.0).round() / 1_000.0 +} + +fn live_execution() -> ExecutionContext { + ExecutionContext { + mode: ExecutionMode::LiveCapture, + effects_allowed: true, + session_id: Some("a1-protocol-validation".into()), + } +} + +fn service_request( + plugin: &StageAModulationPlugin, + id: u64, + command: ModulationCommandV1, +) -> PluginServiceRequest { + let mut payload = ModulationRequestV1::new( + stage_a_plugin_contract::RequestId(id), + ClientId::from("stage-a.a1"), + command, + ); + payload.target_owner_instance = Some(plugin.owner_instance.clone()); + payload.run_id = Some(RunId::from("a1-protocol-validation")); + payload.lease_id = Some(LeaseId::from("a1-protocol-validation")); + payload.issued_at_unix_ms = now_unix_ms(); + PluginServiceRequest { + request_id: id, + source_plugin_id: "stage-a.a1".into(), + target_plugin_id: PLUGIN_ID_STAGE_A_MODULATION.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + payload: serde_json::to_value(payload).expect("serializing modulation request"), + } +} + +fn qualified_service_owner() -> StageAModulationPlugin { + let calibration: Value = + serde_json::from_str(include_str!("../testdata/pockels-20260730-083123.json")) + .expect("recorded Pockels calibration JSON"); + let number = |key: &str| { + calibration[key] + .as_f64() + .unwrap_or_else(|| panic!("calibration has no numeric {key}")) + }; + + let mut plugin = StageAModulationPlugin::default(); + plugin.runtime_role = PluginRuntimeRole::LiveWorker; + plugin.effects_allowed = true; + plugin.port_hint = "mock".into(); + plugin.connect_requested = true; + plugin.max_level = number("max_level").round() as i64; + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.frequency_hz = 0.10; + plugin.depth_a = 1.70; + plugin.operating_point = 0.30; + plugin.v_null_dac = number("v_null_dac").round() as i64; + plugin.v_peak_dac = (number("v_null_dac") + number("v_pi_dac")).round() as i64; + plugin.calibration_id = Some("pockels-20260730-083123".into()); + plugin.connect(); + assert!( + plugin.link.is_some(), + "mock modulation owner did not connect" + ); + plugin +} + +fn assert_service_accepts( + plugin: &mut StageAModulationPlugin, + request_id: u64, + command: ModulationCommandV1, + context: &str, +) { + let request = service_request(plugin, request_id, command); + let reply = plugin.handle_service_request(&request, &live_execution()); + assert!( + matches!(reply.outcome, PluginServiceOutcome::Accepted { .. }), + "{context}: production modulation service rejected the request: {:?}", + reply.outcome + ); +} + +/// Rebuilds the exact optical-log-sine command the owner would send after the +/// A1 service has rounded all three protocol coordinates to milli-units. +fn assert_drive_is_accepted( + fixture: &str, + point: usize, + mean_u: f64, + frequency_hz: f64, + depth_a: f64, + inversion: LobeInversion, + max_code: f64, +) { + let mean_u = milli(mean_u); + let frequency_millihz = (frequency_hz * 1_000.0).round() as u64; + let frequency_hz = frequency_millihz as f64 / 1_000.0; + let depth_a = milli(depth_a); + let context = format!( + "{fixture} point {}: ū={mean_u:.3}, f={frequency_hz:.3}, a={depth_a:.3}", + point + 1 + ); + + assert!( + (MEAN_U_MIN..=1.0).contains(&mean_u), + "{context}: mean_u is outside the modulation service range" + ); + assert!( + (DEPTH_A_MIN..=DEPTH_A_MAX).contains(&depth_a), + "{context}: depth_a is outside the modulation service range" + ); + assert!( + stage_a_plugin_contract::drive_frequency_supported(frequency_millihz), + "{context}: frequency is outside the plugin/firmware range" + ); + + let law = PeakLaw::LogSine; + let u_max = inversion.peak_intensity_ceiling(max_code); + let peak_u = law.peak(mean_u, depth_a); + assert!( + depth_a <= law.max_depth_for_mean(mean_u, u_max) + 1e-9, + "{context}: a exceeds the coupled max-depth calculation" + ); + assert!( + mean_u <= law.max_mean_for_depth(depth_a, u_max) + 1e-9, + "{context}: mean_u exceeds the coupled max-mean calculation" + ); + assert!( + peak_u <= PEAK_U_GUARD + 1e-9, + "{context}: peak u={peak_u:.6} exceeds the protocol guard {PEAK_U_GUARD:.2}" + ); + + // The service publishes the Bessel-normalized geometric pedestal in + // milli-units. Test the rounded table, not an ideal higher-precision one. + let pedestal_u = milli(log_sine_geometric_pedestal(mean_u, depth_a)); + let table = OpticalDrive { + target: OpticalTarget::LogSine, + depth_a, + operating_point: pedestal_u, + inversion, + } + .warp_table() + .unwrap_or_else(|error| panic!("{context}: firmware warp would be refused: {error}")); + let highest_code = table.iter().copied().max().unwrap_or(0); + assert!( + f64::from(highest_code) <= max_code, + "{context}: warp needs DAC {highest_code}, above configured max {max_code:.0}" + ); +} + +/// The protocol sends mean, frequency and depth as three ordered service +/// requests. Validate the intermediate states too: a valid final `(ū, a)` is +/// not enough if changing `ū` first would be rejected against the previous a. +fn assert_protocol_transitions_are_accepted( + fixture: &str, + points: &[ProtocolPoint], + inversion: LobeInversion, + max_code: f64, +) { + // Required pre-flight state in the protocol comments/UI: the armed depth + // must not exceed the largest depth the file will request. + let (mut frequency_hz, mut depth_a) = (0.10, 1.70); + for (index, point) in points.iter().enumerate() { + assert_drive_is_accepted( + fixture, + index, + point.mean_u, + frequency_hz, + depth_a, + inversion, + max_code, + ); + let mean_u = point.mean_u; + assert_drive_is_accepted( + fixture, + index, + mean_u, + point.frequency_hz, + depth_a, + inversion, + max_code, + ); + frequency_hz = point.frequency_hz; + assert_drive_is_accepted( + fixture, + index, + mean_u, + frequency_hz, + point.depth_a, + inversion, + max_code, + ); + depth_a = point.depth_a; + } +} + +/// Runs the parsed rows through the real modulation service boundary. This is +/// deliberately in addition to the named policy assertions above: a change in +/// lease, mode, lobe, rounding, `drive_command` or service sequencing must make +/// the laboratory fixtures fail here rather than drift from production. +fn assert_production_service_accepts_protocol(fixture: &str, points: &[ProtocolPoint]) { + let mut plugin = qualified_service_owner(); + let mut request_id = 1; + assert_service_accepts( + &mut plugin, + request_id, + ModulationCommandV1::AcquireLease { ttl_ms: 60_000 }, + fixture, + ); + + for (index, point) in points.iter().enumerate() { + let context = format!("{fixture} point {}", index + 1); + for command in [ + ModulationCommandV1::SetOperatingPoint { + mean_u_milli: (point.mean_u * 1_000.0).round() as u32, + }, + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: (point.frequency_hz * 1_000.0).round() as u64, + }, + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: (point.depth_a * 1_000.0).round() as u32, + }, + ] { + request_id += 1; + assert_service_accepts(&mut plugin, request_id, command, &context); + } + } + plugin.disconnect(); +} + +#[test] +fn shipped_a1_protocols_parse_and_every_retarget_is_drivable() { + let (inversion, max_code) = qualified_lobe(); + for fixture in fixtures() { + let protocol = parse_csv(fixture.csv) + .unwrap_or_else(|error| panic!("{} does not parse: {error}", fixture.name)); + assert_eq!( + protocol.points.len(), + fixture.expected_points, + "{} changed recording count", + fixture.name + ); + assert_protocol_transitions_are_accepted( + fixture.name, + &protocol.points, + inversion, + max_code, + ); + assert_production_service_accepts_protocol(fixture.name, &protocol.points); + + // On the bench these files live in Playground. When that sibling tree + // exists, make drift from the versioned, shipped fixture a test failure. + let live = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../Playground/protocols") + .join(fixture.name); + if live.is_file() { + let live_text = std::fs::read_to_string(&live) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", live.display())); + assert_eq!( + live_text, + fixture.csv, + "{} differs from the protocol shipped and validated by the plugin", + live.display() + ); + } + } +} From 4aa2cc4b321055681db129aeeb43df50ce9d4fec Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:01 +0200 Subject: [PATCH 06/11] =?UTF-8?q?feat(stage-a-photodiode):=20=E2=9C=A8=20m?= =?UTF-8?q?easure=20the=20direct=20camera=20and=20emission=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector could only be read in the PBS rejected port, where a DC dark level cancels out of the complement. A direct sample of the camera or emission path has no complement to cancel against, so those placements need a real lamp-off reference and refuse `a` until one is captured. The dark value is activated by an explicit button, not by the numeric field alone: a settings replay from the UI mirror would otherwise be able to overwrite a captured reference with a stale draft. Placement is the scientific geometry and is kept separate from the RAW/EXCITATION chart mode, which is only a display choice. The shipped A1 laboratory protocols are parsed as fixtures and checked against the production ring at the bench sample rate, so a sub-hertz rung that could not retain two whole cycles is caught here. --- plugins/stage-a-photodiode/Cargo.toml | 3 + plugins/stage-a-photodiode/README.md | 24 +- plugins/stage-a-photodiode/src/lib.rs | 560 ++++++++++++++++-- .../src/protocol_validation_tests.rs | 89 +++ 4 files changed, 633 insertions(+), 43 deletions(-) create mode 100644 plugins/stage-a-photodiode/src/protocol_validation_tests.rs diff --git a/plugins/stage-a-photodiode/Cargo.toml b/plugins/stage-a-photodiode/Cargo.toml index 5042600..e6b4c6a 100644 --- a/plugins/stage-a-photodiode/Cargo.toml +++ b/plugins/stage-a-photodiode/Cargo.toml @@ -17,3 +17,6 @@ serialport.workspace = true # already a direct dependency here, so it adds nothing new to the build. stage-a-io = { path = "../../stage-a-io" } stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } + +[dev-dependencies] +augur-plugin-stage-a-a1 = { path = "../stage-a-a1" } diff --git a/plugins/stage-a-photodiode/README.md b/plugins/stage-a-photodiode/README.md index da03995..a04b711 100644 --- a/plugins/stage-a-photodiode/README.md +++ b/plugins/stage-a-photodiode/README.md @@ -5,10 +5,27 @@ free-running PDA1 `SamplesU16` stream the `stage-a-controller` firmware (0.4.0+) **second** USB serial port (20 kSa/s default). The port carries no commands, so this plugin is read-only by construction; the command port belongs to `stage-a-modulation`. -## Modes +## Detector placement and modes + +Set **Detector placement** to the physical geometry before recording: + +- **PBS rejected port** — complementary excitation. This is the legacy mode and + uses the learned `I_tot` anchor described below. +- **camera path (direct)** — direct sample of the path sent to the camera. +- **emission path (direct fluorescence)** — direct fluorescence after the + emission filter. Set **Fraction sent to PD** to the beamsplitter fraction + (`0.5` for 50:50), block the beam and press **Capture lamp-off dark**. + +The two direct modes compute `a = ln((V_max-D)/(V_min-D))`. They never use +`I_tot`. The splitter fraction is written as provenance and is not used to +rescale log contrast. Direct-path `a` is withheld until a lamp-off dark has +been explicitly captured. The PD artifacts record its value, ID, source, +capture time, and age. The numeric dark field is only a draft; pressing **Use +manual dark** activates it with source `manual`. This explicit step prevents UI +settings replay from replacing a captured lamp-off reference. - **RAW** — shows the ADC code and its voltage, `V = code · 3.3 / 4095`. -- **EXCITATION** — the photodiode sits in the excitation path behind the PBS and measures the +- **EXCITATION** — in rejected-port placement, the photodiode sits behind the PBS and measures the light *removed* from the beam: `I_pd = I_tot − I_exc`, so the plugin shows `I_exc = I_tot − I_pd`. `I_tot` is **learned, not entered**: it is the brightest smoothed reading the detector has taken since the port opened, which on the reject port is where the excitation is extinguished. The @@ -16,6 +33,9 @@ read-only by construction; the command port belongs to `stage-a-modulation`. anchor. There is no dark level either — a DC offset cancels exactly out of the complement. See [ADR 024](../../docs/adr/024-stage-a-photodiode-learns-its-own-anchor.md). +RAW/EXCITATION is a display choice. Detector placement is the scientific +geometry and controls the estimator independently of the chart mode. + ## Views - a live rolling chart (window length settable, 1–120 s) of the value in the selected mode; diff --git a/plugins/stage-a-photodiode/src/lib.rs b/plugins/stage-a-photodiode/src/lib.rs index 840843f..e5072f7 100644 --- a/plugins/stage-a-photodiode/src/lib.rs +++ b/plugins/stage-a-photodiode/src/lib.rs @@ -21,6 +21,9 @@ //! or — for modulated signals — one full period of a user-given frequency, //! which makes the mean independent of the modulation phase. +#[cfg(test)] +mod protocol_validation_tests; + use std::collections::{BTreeMap, VecDeque}; use std::fs::{File, OpenOptions}; use std::io::{BufWriter, Read, Write}; @@ -47,7 +50,8 @@ use stage_a_io::{ use stage_a_plugin_contract::{ ClientId, ConnectionStateV1, FreshnessV1, LeaseId, LeaseSnapshotV1, OwnerInstanceId, PdqFinalizedReceiptV1, PdqReceiptV1, PdqStartSpecV1, PdqStartedReceiptV1, PdqTerminationV1, - PhotodiodeCalibrationV1, PhotodiodeCommandV1, PhotodiodeLevelV1, PhotodiodeOpticalSummaryV1, + PhotodiodeCalibrationV1, PhotodiodeCommandV1, PhotodiodeDarkReferenceV1, + PhotodiodeDarkSourceV1, PhotodiodeLevelV1, PhotodiodeOpticalSummaryV1, PhotodiodePlacementV1, PhotodiodeRequestV1, PhotodiodeResponseV1, PhotodiodeStreamV1, PhotodiodeSummaryV1, RequestOutcomeV1, ResponseCommonV1, RunId, SampleRangeV1, SemanticRevision, ServiceErrorCodeV1, ServiceErrorV1, Sha256V1, StreamIntegrityV1, SynchronizationV1, UnsyncedReasonV1, @@ -124,6 +128,26 @@ enum Mode { Excitation, } +const PHOTODIODE_PLACEMENTS: [PhotodiodePlacementV1; 3] = [ + PhotodiodePlacementV1::RejectedPort, + PhotodiodePlacementV1::CameraPath, + PhotodiodePlacementV1::EmissionPath, +]; + +fn placement_name(placement: PhotodiodePlacementV1) -> &'static str { + match placement { + PhotodiodePlacementV1::RejectedPort => "PBS rejected port", + PhotodiodePlacementV1::CameraPath => "camera path (direct)", + PhotodiodePlacementV1::EmissionPath => "emission path (direct fluorescence)", + } +} + +fn placement_from_name(name: &str) -> Option { + PHOTODIODE_PLACEMENTS + .into_iter() + .find(|placement| placement_name(*placement) == name) +} + impl Mode { const VARIANTS: [Mode; 2] = [Mode::Raw, Mode::Excitation]; @@ -185,6 +209,10 @@ struct SharedState { /// `Marker` stream frames. Used for the opt-in trigger overlay and to derive /// the modulation frequency. markers: VecDeque, + /// A2 optical-comparator crossings (`MarkerPayload::source == 2`). Kept + /// separate from A1 phase-0 markers so neither experiment can silently + /// derive a frequency from the other experiment's fiducials. + comparator_markers: VecDeque<(u64, u8)>, /// Newest phase-0 marker index seen, retained or already evicted, and the /// spacing to the one before it. /// @@ -256,6 +284,7 @@ impl Default for SharedState { samples: VecDeque::new(), cells: VecDeque::new(), markers: VecDeque::new(), + comparator_markers: VecDeque::new(), last_marker_index: None, marker_period_estimate: None, latest: None, @@ -329,6 +358,7 @@ impl SharedState { self.samples.clear(); self.cells.clear(); self.markers.clear(); + self.comparator_markers.clear(); // The sample clock restarts with the segment, so a spacing // measured across the discontinuity is meaningless. self.last_marker_index = None; @@ -386,6 +416,13 @@ impl SharedState { { self.markers.pop_front(); } + while self + .comparator_markers + .front() + .is_some_and(|&(index, _)| index < self.ring_first_index) + { + self.comparator_markers.pop_front(); + } } /// Records a phase-0 marker (device sample index) if it sits inside the @@ -414,6 +451,24 @@ impl SharedState { self.last_update_unix_ms = now_unix_ms(); } + fn push_comparator_marker(&mut self, sample_index: u64, level: u8) { + if sample_index < self.ring_first_index { + return; + } + if self + .comparator_markers + .back() + .is_some_and(|&(last, _)| last == sample_index) + { + return; + } + self.comparator_markers.push_back((sample_index, level)); + while self.comparator_markers.len() > MAX_MARKERS { + self.comparator_markers.pop_front(); + } + self.last_update_unix_ms = now_unix_ms(); + } + /// How many trailing samples the optical log-contrast is estimated over, /// with the whole modulation cycles that window covers. /// @@ -763,7 +818,15 @@ fn ingest_parse_event( // samples, hence a sample count of 0. record_frame(recording, &frame, 0); if let Ok(mut state) = shared.lock() { - state.push_marker(marker.sample_index); + match marker.source { + stage_a_io::wire::MARKER_SOURCE_PHASE0 => { + state.push_marker(marker.sample_index); + } + stage_a_io::wire::MARKER_SOURCE_COMPARATOR => { + state.push_comparator_marker(marker.sample_index, marker.level); + } + _ => {} + } } return true; } @@ -816,6 +879,19 @@ pub struct StageAPhotodiodePlugin { connect_requested: bool, port_hint: String, mode: Mode, + /// Physical detector geometry. This changes the scientific contrast + /// transform, unlike `mode`, which is display-only. + placement: PhotodiodePlacementV1, + /// Fraction of the local beam delivered to the detector. Used only as + /// provenance because a fixed factor cancels from log contrast. + splitter_fraction: f64, + /// Session-local lamp-off reading for direct camera/emission paths. `None` + /// is a scientific gate, not an implicit zero-dark calibration. + direct_dark_reference: Option, + /// UI-synchronized draft. It becomes a calibration only through the + /// explicit `Use manual dark` button, so settings replay cannot overwrite + /// a measured reference. + direct_dark_manual_volts: f64, window_s: f64, avg_samples: usize, avg_sync_freq_hz: f64, @@ -825,6 +901,8 @@ pub struct StageAPhotodiodePlugin { data_dir: String, // -- momentary-button press forwarding (see PressLatch) -- press_save_snapshot: PressLatch, + press_capture_direct_dark: PressLatch, + press_use_manual_direct_dark: PressLatch, press_record_start: PressLatch, press_record_stop: PressLatch, } @@ -882,6 +960,26 @@ struct ControlLease { expires_at_unix_ms: u64, } +#[derive(Debug, Clone)] +struct StoredDirectDarkReference { + dark_id: String, + source: PhotodiodeDarkSourceV1, + dark_volts: f64, + captured_at_unix_ms: u64, +} + +impl StoredDirectDarkReference { + fn published(&self, observed_at_unix_ms: u64) -> PhotodiodeDarkReferenceV1 { + PhotodiodeDarkReferenceV1 { + dark_id: self.dark_id.clone(), + source: self.source, + dark_volts: self.dark_volts, + captured_at_unix_ms: self.captured_at_unix_ms, + age_s: observed_at_unix_ms.saturating_sub(self.captured_at_unix_ms) as f64 / 1_000.0, + } + } +} + impl Default for StageAPhotodiodePlugin { fn default() -> Self { Self { @@ -909,6 +1007,10 @@ impl Default for StageAPhotodiodePlugin { connect_requested: false, port_hint: "auto".into(), mode: Mode::Raw, + placement: PhotodiodePlacementV1::RejectedPort, + splitter_fraction: 0.5, + direct_dark_reference: None, + direct_dark_manual_volts: 0.0, window_s: 10.0, avg_samples: 4, avg_sync_freq_hz: 0.0, @@ -916,6 +1018,8 @@ impl Default for StageAPhotodiodePlugin { show_markers: false, data_dir: String::new(), press_save_snapshot: PressLatch::default(), + press_capture_direct_dark: PressLatch::default(), + press_use_manual_direct_dark: PressLatch::default(), press_record_start: PressLatch::default(), press_record_stop: PressLatch::default(), } @@ -945,6 +1049,18 @@ impl StageAPhotodiodePlugin { } } + fn published_direct_dark(&self) -> Option { + self.direct_dark_reference + .as_ref() + .map(|reference| reference.published(now_unix_ms())) + } + + fn direct_dark_volts(&self) -> Option { + self.direct_dark_reference + .as_ref() + .map(|reference| reference.dark_volts) + } + /// The learned total-power anchor `I_tot` in volts, if the stream has run /// long enough to complete one summary cell. fn total_power_volts(&self, state: &SharedState) -> Option { @@ -1370,8 +1486,17 @@ impl StageAPhotodiodePlugin { "termination": receipt.termination, "adc": { "bits": 12, "full_scale_volts": ADC_FULL_SCALE_VOLTS }, "display_mode": self.mode.name(), - "total_power_volts": self.learned_anchor_volts(), - "total_power_source": "observed-peak", + "photodiode_placement": self.placement, + "splitter_fraction": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), + "direct_dark_volts": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.direct_dark_volts()).flatten(), + "direct_dark_reference": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()).flatten(), + "total_power_volts": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.learned_anchor_volts()).flatten(), + "total_power_source": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then_some("observed-peak"), "integrity": { "resync_bytes": summary.integrity.skipped_bytes, "crc_failures": summary.integrity.crc_failures, @@ -1635,13 +1760,11 @@ impl StageAPhotodiodePlugin { /// Live optical log-contrast `a` from a marker-bounded ring window. /// - /// The detector sits behind the PBS reject port and measures the rejected - /// complement `I_pd = I_tot - I_exc` — that is a property of the optical - /// bench, settled by construction (knowledge base: - /// `setup/optical-path.md`), not of what the operator chose to plot. So the - /// geometry is always [`ContrastGeometry::RejectedComplement`] anchored on - /// [`SharedState::observed_peak_code`], and `measured_log_contrast` is - /// always the *excitation* contrast `a = ln(I_exc,max / I_exc,min)`. + /// The physical placement selects the transform. The historical PBS + /// rejected port measures a complement and therefore needs its observed + /// full-extinction anchor. Camera/emission-path placements measure their + /// local beam directly and use the lamp-off dark reference; `I_tot` is not + /// defined or consulted in those geometries. /// /// The display [`Mode`] is presentational only. It must never reach this /// function: A1's amplitude sweep settles on this value against a target @@ -1656,9 +1779,21 @@ impl StageAPhotodiodePlugin { &self, state: &SharedState, ) -> Result { - let total_power_volts = self - .total_power_volts(state) - .ok_or(EstimateError::MissingTotalPowerAnchor)?; + let total_power_volts = match self.placement { + PhotodiodePlacementV1::RejectedPort => Some( + self.total_power_volts(state) + .ok_or(EstimateError::MissingTotalPowerAnchor)?, + ), + PhotodiodePlacementV1::CameraPath | PhotodiodePlacementV1::EmissionPath => None, + }; + let direct_dark = match self.placement { + PhotodiodePlacementV1::RejectedPort => None, + PhotodiodePlacementV1::CameraPath | PhotodiodePlacementV1::EmissionPath => Some( + self.direct_dark_reference + .as_ref() + .ok_or(EstimateError::MissingDirectDarkReference)?, + ), + }; let ring_end = state.ring_first_index + state.samples.len() as u64; let markers: Vec = state @@ -1688,12 +1823,16 @@ impl StageAPhotodiodePlugin { let rate_hz = f64::from(state.rate_hz.max(1)); let covered_cycles = Some((markers.len() - 1 - start_marker) as f64); let window_seconds = end_index.saturating_sub(start_index) as f64 / rate_hz; - let calibration = self.adc_calibration(); - // The anchor and the samples come from the same DC-coupled detector, so - // any dark offset appears identically on both sides of the complement - // and cancels exactly. Nothing here is dark-corrected, and that is the - // physically right answer — see [`Self::adc_calibration`]. - let geometry = ContrastGeometry::RejectedComplement { total_power_volts }; + let mut calibration = self.adc_calibration(); + let geometry = match total_power_volts { + Some(total_power_volts) => ContrastGeometry::RejectedComplement { total_power_volts }, + None => { + calibration.dark_volts = direct_dark + .expect("direct placement checked above") + .dark_volts; + ContrastGeometry::Direct + } + }; let estimate = estimate_contrast(&window, &calibration, geometry)?; let run_id = self .lease @@ -1706,13 +1845,23 @@ impl StageAPhotodiodePlugin { adc_calibration_id: "adc-default".into(), // The complement is dark-invariant, so there is no dark level // to name — say that rather than imply an unmeasured zero. - dark_id: "dark-cancels".into(), + dark_id: match self.placement { + PhotodiodePlacementV1::RejectedPort => "dark-cancels".into(), + _ => direct_dark + .expect("direct placement checked above") + .dark_id + .clone(), + }, // Provenance for an anchor nobody typed: the detector sample // index the learned peak was still valid at. - anchor_id: format!("observed-peak@{ring_end}"), + anchor_id: total_power_volts.map(|_| format!("observed-peak@{ring_end}")), dark_volts: calibration.dark_volts, + dark_reference: direct_dark.map(|reference| reference.published(now_unix_ms())), total_power_volts, }, + placement: self.placement, + splitter_fraction: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), measured_log_contrast: estimate.a, log_contrast_stddev: None, excitation_min_volts: estimate.v_min_volts, @@ -1867,6 +2016,12 @@ impl StageAPhotodiodePlugin { active_recording, last_finalized_recording: self.last_finalized_recording.clone(), optical_summary, + placement: self.placement, + splitter_fraction: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), + dark_reference: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()) + .flatten(), optical_unavailable, synchronization, last_response: self.last_response.clone(), @@ -1996,8 +2151,17 @@ impl StageAPhotodiodePlugin { "csv_path": csv_path, "adc": { "bits": 12, "full_scale_volts": ADC_FULL_SCALE_VOLTS }, "display_mode": self.mode.name(), - "total_power_volts": self.learned_anchor_volts(), - "total_power_source": "observed-peak", + "photodiode_placement": self.placement, + "splitter_fraction": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), + "direct_dark_volts": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.direct_dark_volts()).flatten(), + "direct_dark_reference": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()).flatten(), + "total_power_volts": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.learned_anchor_volts()).flatten(), + "total_power_source": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then_some("observed-peak"), "time_base": "t_s = sample_index / sample_rate_hz, device clock, segment-relative", "integrity": integrity, }); @@ -2017,9 +2181,15 @@ impl StageAPhotodiodePlugin { /// very first repaint; it shows the raw reading rather than a trace /// referenced to a number that does not exist yet. fn display_volts(&self, code: f64, anchor_volts: Option) -> f64 { - match (self.mode, anchor_volts) { - (Mode::Raw, _) | (Mode::Excitation, None) => code_to_volts(code), - (Mode::Excitation, Some(anchor)) => anchor - code_to_volts(code), + match (self.mode, self.placement, anchor_volts) { + (Mode::Raw, _, _) => code_to_volts(code), + (Mode::Excitation, PhotodiodePlacementV1::RejectedPort, Some(anchor)) => { + anchor - code_to_volts(code) + } + (Mode::Excitation, PhotodiodePlacementV1::RejectedPort, None) => code_to_volts(code), + (Mode::Excitation, _, _) => self + .direct_dark_volts() + .map_or(code_to_volts(code), |dark| code_to_volts(code) - dark), } } @@ -2144,7 +2314,9 @@ impl StageAPhotodiodePlugin { let avg_window = self.avg_window_samples(state.rate_hz); let avg_enabled = avg_window > 1; - let anchor = self.total_power_volts(&state); + let anchor = (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.total_power_volts(&state)) + .flatten(); let mut mean_points = Vec::with_capacity(MAX_PLOT_BUCKETS + 1); let mut min_points = Vec::with_capacity(if decimating { MAX_PLOT_BUCKETS + 1 } else { 0 }); @@ -2354,7 +2526,9 @@ impl StageAPhotodiodePlugin { state.device_dropped, state.crc_failures, state.resync_bytes, state.segments ), state.error.clone(), - self.total_power_volts(&state), + (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.total_power_volts(&state)) + .flatten(), ), Err(_) => (None, 0, None, String::new(), None, None), }; @@ -2700,7 +2874,7 @@ impl Plugin for StageAPhotodiodePlugin { } fn description(&self) -> &'static str { - "Live photodiode readout (SMA5/pin 18/A4) from the Teensy PDA1 stream port at the full stream rate: raw values or excitation power I_exc = I_tot − I_pd with a user-set reference." + "Live photodiode readout with explicit rejected-port, camera-path or emission-path optical geometry and coordinated PDQ recording." } fn enabled(&self) -> bool { @@ -2876,6 +3050,14 @@ impl Plugin for StageAPhotodiodePlugin { .iter() .position(|m| *m == self.mode) .unwrap_or(0); + let placement_variants: Vec = PHOTODIODE_PLACEMENTS + .iter() + .map(|placement| placement_name(*placement).to_owned()) + .collect(); + let placement_default = PHOTODIODE_PLACEMENTS + .iter() + .position(|placement| *placement == self.placement) + .unwrap_or(0); SettingsSchema { sections: vec![ SettingsSection { @@ -2883,12 +3065,10 @@ impl Plugin for StageAPhotodiodePlugin { description: Some( "Reads the photodiode on the Teensy's SECOND serial port. This is what \ measures the modulation depth the A1 plugin records against.\n\n\ - The detector sits behind the beamsplitter, so it sees the light taken \ - *out* of the excitation beam. The total power I_tot is the brightest \ - reading it has taken since the port opened — the excitation is fully \ - extinguished there, so that reading is I_tot by construction. Nothing \ - to enter: the Pockels transfer sweep walks the whole lobe and lands on \ - it. EXCITATION mode subtracts the live reading from it." + Set the physical detector placement before recording. The PBS rejected \ + port uses the complementary-light I_tot model. Camera and emission paths \ + measure the local beam directly, use the lamp-off dark reference, and \ + never use I_tot." .into(), ), default_open: true, @@ -2918,6 +3098,75 @@ impl Plugin for StageAPhotodiodePlugin { default: self.connect_requested, }, }, + SettingItem { + key: "placement".into(), + label: "Detector placement".into(), + tooltip: Some( + "PBS rejected port: complementary excitation, needs the learned \ + I_tot anchor. Camera path: direct beam towards the camera. \ + Emission path: direct fluorescence after the emission filter." + .into(), + ), + kind: SettingKind::Enum { + variants: placement_variants, + default: placement_default, + }, + }, + SettingItem { + key: "splitter_fraction".into(), + label: "Fraction sent to PD".into(), + tooltip: Some( + "0.5 for a 50:50 splitter. Stored as optical provenance; it is \ + not used to rescale log contrast. Ignored for the PBS rejected port." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.001, + max: 1.0, + speed: 0.01, + default: self.splitter_fraction, + }, + }, + SettingItem { + key: "direct_dark_volts".into(), + label: "Lamp-off dark (V)".into(), + tooltip: Some( + "Session-local blocked-light detector reading for camera/emission \ + path contrast. This is only a draft until Use manual dark is \ + pressed. Ignored for the PBS rejected port." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.0, + max: ADC_FULL_SCALE_VOLTS, + speed: 0.0001, + default: self.direct_dark_manual_volts, + }, + }, + SettingItem { + key: "use_manual_direct_dark".into(), + label: "Use manual dark".into(), + tooltip: Some( + "Explicitly activates the typed value and records its source as \ + manual. Prefer Capture lamp-off dark when the detector is available." + .into(), + ), + kind: SettingKind::Button { + enabled: self.placement != PhotodiodePlacementV1::RejectedPort, + }, + }, + SettingItem { + key: "capture_direct_dark".into(), + label: "Capture lamp-off dark".into(), + tooltip: Some( + "With the beam physically blocked, freezes the current settled \ + raw detector level as the session dark reference." + .into(), + ), + kind: SettingKind::Button { + enabled: self.placement != PhotodiodePlacementV1::RejectedPort, + }, + }, SettingItem { key: "mode".into(), label: "Mode".into(), @@ -3108,6 +3357,14 @@ impl Plugin for StageAPhotodiodePlugin { Some(json!(index)) } "connect" => Some(json!(self.connect_requested)), + "placement" => Some(json!(PHOTODIODE_PLACEMENTS + .iter() + .position(|placement| *placement == self.placement) + .unwrap_or(0))), + "splitter_fraction" => Some(json!(self.splitter_fraction)), + "direct_dark_volts" => Some(json!(self.direct_dark_manual_volts)), + "use_manual_direct_dark" => Some(self.press_use_manual_direct_dark.value()), + "capture_direct_dark" => Some(self.press_capture_direct_dark.value()), "mode" => { let index = Mode::VARIANTS .iter() @@ -3156,6 +3413,83 @@ impl Plugin for StageAPhotodiodePlugin { self.port_hint = variant_path(&enum_choice(&value, &port_variants())?).to_owned(); Ok(()) } + "placement" => { + let names: Vec = PHOTODIODE_PLACEMENTS + .iter() + .map(|placement| placement_name(*placement).to_owned()) + .collect(); + let name = enum_choice(&value, &names)?; + let placement = placement_from_name(&name) + .ok_or_else(|| format!("unknown photodiode placement: {name}"))?; + if placement != self.placement { + self.placement = placement; + self.direct_dark_reference = None; + } + Ok(()) + } + "splitter_fraction" => { + let fraction = value.as_f64().ok_or("splitter_fraction must be a number")?; + if !(fraction.is_finite() && 0.0 < fraction && fraction <= 1.0) { + return Err("splitter_fraction must be in (0, 1]".into()); + } + self.splitter_fraction = fraction; + Ok(()) + } + "direct_dark_volts" => { + let volts = value.as_f64().ok_or("direct_dark_volts must be a number")?; + if !volts.is_finite() || !(0.0..ADC_FULL_SCALE_VOLTS).contains(&volts) { + return Err(format!( + "direct_dark_volts must be in [0, {ADC_FULL_SCALE_VOLTS})" + )); + } + self.direct_dark_manual_volts = volts; + Ok(()) + } + "use_manual_direct_dark" => { + if self.press_use_manual_direct_dark.accept(&value) { + if self.placement == PhotodiodePlacementV1::RejectedPort { + return Err("lamp-off dark is not used for the PBS rejected port".into()); + } + let captured_at_unix_ms = now_unix_ms(); + self.direct_dark_reference = Some(StoredDirectDarkReference { + dark_id: format!("manual@{captured_at_unix_ms}"), + source: PhotodiodeDarkSourceV1::Manual, + dark_volts: self.direct_dark_manual_volts, + captured_at_unix_ms, + }); + self.last_save_note = Some(format!( + "using manually entered dark: {:.6} V", + self.direct_dark_manual_volts + )); + } + Ok(()) + } + "capture_direct_dark" => { + if self.press_capture_direct_dark.accept(&value) { + if self.placement == PhotodiodePlacementV1::RejectedPort { + return Err("lamp-off dark is not used for the PBS rejected port".into()); + } + let level = self + .shared + .lock() + .ok() + .and_then(|state| self.current_level(&state)) + .ok_or("no settled photodiode level is available")?; + let volts = level.mean_volts; + let captured_at_unix_ms = now_unix_ms(); + self.direct_dark_reference = Some(StoredDirectDarkReference { + dark_id: format!( + "lamp-off@sample-{}@{captured_at_unix_ms}", + level.end_sample_index + ), + source: PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: volts, + captured_at_unix_ms, + }); + self.last_save_note = Some(format!("captured lamp-off dark: {volts:.6} V")); + } + Ok(()) + } "connect" => { let requested = value.as_bool().ok_or("connect must be a boolean")?; self.connect_requested = requested; @@ -3289,7 +3623,9 @@ impl Plugin for StageAPhotodiodePlugin { state.rate_hz, self.current_average_code(&state), state.error.clone(), - self.total_power_volts(&state), + (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.total_power_volts(&state)) + .flatten(), ), Err(_) => (None, 0, None, None, None), }; @@ -3302,6 +3638,26 @@ impl Plugin for StageAPhotodiodePlugin { } else { "Photodiode: disconnected".into() })); + entries.push(StatusEntry::Text(match self.placement { + PhotodiodePlacementV1::RejectedPort => { + "Optics: PBS rejected port (I_tot complement)".into() + } + placement => match self.published_direct_dark() { + Some(dark) => format!( + "Optics: {} (PD fraction {:.1} %, dark {:.4} V, {:?}, age {:.1} s)", + placement_name(placement), + 100.0 * self.splitter_fraction, + dark.dark_volts, + dark.source, + dark.age_s + ), + None => format!( + "Optics: {} (PD fraction {:.1} %, lamp-off dark REQUIRED)", + placement_name(placement), + 100.0 * self.splitter_fraction + ), + }, + })); if let Some(sample) = latest { match self.mode { Mode::Raw => entries.push(StatusEntry::Text(format!( @@ -3333,7 +3689,7 @@ impl Plugin for StageAPhotodiodePlugin { // not the display mode. Some(Ok(optical)) => { entries.push(StatusEntry::Text(format!( - "a (excitation) = {:.3} (I {:.4}..{:.4} V)", + "a (local optical signal) = {:.3} (I {:.4}..{:.4} V)", optical.measured_log_contrast, optical.excitation_min_volts, optical.excitation_max_volts @@ -3667,9 +4023,13 @@ mod tests { let excitation = plugin.optical_summary(&state).expect("excitation display"); assert_eq!(raw.measured_log_contrast, excitation.measured_log_contrast); - assert!(raw.calibration.anchor_id.starts_with("observed-peak@")); + assert!(raw + .calibration + .anchor_id + .as_deref() + .is_some_and(|anchor| anchor.starts_with("observed-peak@"))); assert_eq!(raw.calibration.anchor_id, excitation.calibration.anchor_id); - assert!((raw.calibration.total_power_volts - 3.0).abs() < 1e-12); + assert!((raw.calibration.total_power_volts.expect("I_tot") - 3.0).abs() < 1e-12); assert!((raw.measured_frequency_hz.expect("marker frequency") - 39.0625).abs() < 1e-12); // And it really is the complement contrast, not ln(v_max/v_min) of the @@ -3682,6 +4042,104 @@ mod tests { ); } + #[test] + fn emission_path_measures_direct_contrast_without_i_tot() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + splitter_fraction: 0.5, + direct_dark_reference: Some(StoredDirectDarkReference { + dark_id: "lamp-off@test".into(), + source: PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: 0.0, + captured_at_unix_ms: now_unix_ms(), + }), + ..StageAPhotodiodePlugin::default() + }; + let state = rejected_port_state(1_600.0, 700.0, 4_096, true); + let optical = plugin + .optical_summary_result(&state) + .expect("direct fluorescence contrast"); + + assert_eq!(optical.placement, PhotodiodePlacementV1::EmissionPath); + assert_eq!(optical.splitter_fraction, Some(0.5)); + assert!(optical.measured_log_contrast.is_finite()); + assert!(optical.measured_log_contrast > 0.0); + assert!(optical.calibration.anchor_id.is_none()); + assert!(optical.calibration.total_power_volts.is_none()); + assert_eq!( + optical + .calibration + .dark_reference + .as_ref() + .map(|reference| reference.source), + Some(PhotodiodeDarkSourceV1::MeasuredLampOff) + ); + + // Even a completely empty rejected-port anchor must not gate a direct + // path measurement. + plugin.shared = Arc::new(Mutex::new(state)); + let summary = plugin.control_summary(); + assert_eq!(summary.placement, PhotodiodePlacementV1::EmissionPath); + assert_eq!(summary.splitter_fraction, Some(0.5)); + assert!(summary.optical_summary.is_some()); + } + + #[test] + fn direct_path_refuses_contrast_until_dark_is_explicit() { + let plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + ..StageAPhotodiodePlugin::default() + }; + let state = rejected_port_state(1_600.0, 700.0, 4_096, true); + + assert_eq!( + plugin.optical_summary_result(&state), + Err(EstimateError::MissingDirectDarkReference) + ); + } + + #[test] + fn manual_direct_dark_is_marked_manual() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + ..StageAPhotodiodePlugin::default() + }; + plugin + .set_setting("direct_dark_volts", json!(0.012)) + .expect("manual dark draft"); + assert!(plugin.published_direct_dark().is_none()); + plugin + .set_setting("use_manual_direct_dark", json!(true)) + .expect("activate manual dark"); + + let reference = plugin + .published_direct_dark() + .expect("manual reference published"); + assert_eq!(reference.source, PhotodiodeDarkSourceV1::Manual); + assert!(reference.dark_id.starts_with("manual@")); + assert_eq!(reference.dark_volts, 0.012); + } + + #[test] + fn captured_direct_dark_names_the_measured_sample_window() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + shared: Arc::new(Mutex::new(rejected_port_state(16.0, 2.0, 4_096, true))), + ..StageAPhotodiodePlugin::default() + }; + plugin + .set_setting("capture_direct_dark", json!(true)) + .expect("capture dark"); + + let reference = plugin + .published_direct_dark() + .expect("measured reference published"); + assert_eq!(reference.source, PhotodiodeDarkSourceV1::MeasuredLampOff); + assert!(reference.dark_id.starts_with("lamp-off@sample-4096@")); + assert!(reference.captured_at_unix_ms > 0); + assert!(reference.age_s >= 0.0); + } + #[test] fn optical_contrast_requires_a_learned_anchor_and_complete_cycles() { let plugin = live_plugin(); @@ -4450,7 +4908,7 @@ mod tests { payload.extend_from_slice(&sample_index.to_le_bytes()); payload.extend_from_slice(&0_u32.to_le_bytes()); // tick_us payload.push(1); // level - payload.push(0); // source + payload.push(stage_a_io::wire::MARKER_SOURCE_PHASE0); // source payload.extend_from_slice(&[0, 0]); // reserved Frame::build( FrameHeader { @@ -4468,6 +4926,26 @@ mod tests { ) } + #[test] + fn comparator_markers_never_enter_the_a1_phase_ring() { + let shared = Arc::new(Mutex::new(SharedState::default())); + let recording: SharedRecording = Arc::new(Mutex::new(None)); + { + let mut state = shared.lock().unwrap(); + state.ingest(0, MOCK_RATE_HZ, 0, &[100, 200, 300, 400]); + } + let mut frame = marker_frame(1, 2); + frame.payload[13] = stage_a_io::wire::MARKER_SOURCE_COMPARATOR; + assert!(ingest_parse_event( + ParseEvent::Frame(frame), + &shared, + &recording, + )); + let state = shared.lock().unwrap(); + assert!(state.markers.is_empty()); + assert_eq!(state.comparator_markers.back().copied(), Some((2, 1))); + } + #[test] fn phase_zero_markers_are_written_into_the_recording() { // Without the marker frames a recorded run cannot be phase-attributed diff --git a/plugins/stage-a-photodiode/src/protocol_validation_tests.rs b/plugins/stage-a-photodiode/src/protocol_validation_tests.rs new file mode 100644 index 0000000..9fce2ae --- /dev/null +++ b/plugins/stage-a-photodiode/src/protocol_validation_tests.rs @@ -0,0 +1,89 @@ +//! Verifies that the A1 protocol minima fit the production photodiode ring at +//! the 500 kSa/s bench rate, with the cache length left at its default. + +use augur_plugin_stage_a_a1::protocol::parse_csv; + +use super::{SharedState, DEFAULT_CACHE_SECONDS, RING_MAX_SAMPLES}; + +const BENCH_RATE_HZ: u32 = 500_000; + +#[test] +fn shipped_a1_protocols_retain_two_cycles_at_their_lowest_frequency() { + let fixtures = [ + ( + "a1_triage_90min.csv", + include_str!("../../stage-a-a1/protocols/a1_triage_90min.csv"), + ), + ( + "a1_stufe1_bode_dc.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe1_bode_dc.csv"), + ), + ( + "a1_stufe2_bode_u010.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u010.csv"), + ), + ( + "a1_stufe2_bode_u045.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u045.csv"), + ), + ( + "a1_stufe2_flussleiter.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_flussleiter.csv"), + ), + ]; + + for (name, csv) in fixtures { + let protocol = parse_csv(csv).unwrap_or_else(|error| panic!("{name}: {error}")); + let lowest_hz = protocol + .points + .iter() + .map(|point| point.frequency_hz) + .fold(f64::INFINITY, f64::min); + let required_samples = (2.0 * f64::from(BENCH_RATE_HZ) / lowest_hz).ceil() as usize; + + // The operator sets nothing: the ring sizes itself from the marker + // period once the drive has stamped two phase-0 markers at the file's + // lowest rung. + let mut ring = SharedState { + cache_seconds: DEFAULT_CACHE_SECONDS, + rate_hz: BENCH_RATE_HZ, + ..SharedState::default() + }; + let period = (f64::from(BENCH_RATE_HZ) / lowest_hz) as u64; + ring.push_marker(0); + ring.push_marker(period); + + let capacity = ring.ring_capacity(BENCH_RATE_HZ); + assert!(capacity <= RING_MAX_SAMPLES); + assert!( + required_samples <= capacity, + "{name}: two cycles at {lowest_hz} Hz need {required_samples} samples, but the ring \ + sized itself to {capacity}" + ); + + // Every row must run long enough for the optical summary's required + // three phase markers (two complete cycles), not only the lowest one. + for (index, point) in protocol.points.iter().enumerate() { + let recorded_cycles = point.frequency_hz * point.duration_s as f64; + assert!( + recorded_cycles >= 2.0, + "{name} point {} records only {recorded_cycles:.3} cycles", + index + 1 + ); + } + + // The regression witness: without the marker period, the same default + // cache is far too short for these files. That gap is what used to be + // an operator precondition, and what a whole survey failed on. + if lowest_hz < 0.1 { + let unmarked = SharedState { + cache_seconds: DEFAULT_CACHE_SECONDS, + ..SharedState::default() + }; + assert!( + required_samples > unmarked.ring_capacity(BENCH_RATE_HZ), + "{name}: witness no longer proves the 20 s default is insufficient on its own" + ); + } + } +} From eaebbc6056b73ee992a1699a4545ebe476c79c30 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:14 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(stage-a-a1):=20=E2=9C=A8=20let=20the?= =?UTF-8?q?=20sidecar=20own=20experiment=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 copied the host's camera snapshot, readback, ROI, mask and bias codes into its own sidecar. The copies were not independent measurements and could disagree with the host TOML sitting next to the same RAW, so the v2 sidecar cross-references that file instead of duplicating it and keeps only the conditions that actually move during a run. What the sidecar could not say before, it now says. `[protocol]` names the schedule that produced the point — name, author version, source file, SHA-256 and row identity — and the source is archived once per content hash into the measurement folder, because a path on the operator's disk is not durable provenance. `[depth]` separates the commanded, measured and analysed `a` instead of overlapping names for the three. `[photodiode]` records the detector geometry. The complement model is only correct in the rejected port; applying it to a direct camera or emission-path sample is a scientific error, so the placement travels with every point rather than being assumed. The eight laboratory protocols now in use ship as fixtures. Refs ADR 039. --- ...a-a1-sidecar-owns-experiment-provenance.md | 67 ++ docs/features/stage-a-a1.md | 11 +- plugins/stage-a-a1/Cargo.toml | 1 + plugins/stage-a-a1/README.md | 28 +- plugins/stage-a-a1/plugin.toml | 2 +- .../protocols/a1_fc_flux_discriminator.csv | 333 ++++++++++ .../protocols/a1_illuminated_smoke.csv | 13 + .../protocols/a1_lux_dark_offset.csv | 14 + .../protocols/a1_stufe1_bode_dc.csv | 147 +++++ .../protocols/a1_stufe2_bode_u010.csv | 83 +++ .../protocols/a1_stufe2_bode_u045.csv | 83 +++ .../protocols/a1_stufe2_flussleiter.csv | 286 +++++++++ .../stage-a-a1/protocols/a1_triage_90min.csv | 103 ++++ plugins/stage-a-a1/protocols/example.toml | 1 + plugins/stage-a-a1/src/protocol.rs | 26 +- plugins/stage-a-a1/src/runtime.rs | 572 +++++++++++------- 16 files changed, 1552 insertions(+), 218 deletions(-) create mode 100644 docs/adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md create mode 100644 plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv create mode 100644 plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv create mode 100644 plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv create mode 100644 plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv create mode 100644 plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv create mode 100644 plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv create mode 100644 plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv create mode 100644 plugins/stage-a-a1/protocols/a1_triage_90min.csv diff --git a/docs/adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md b/docs/adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md new file mode 100644 index 0000000..09e8242 --- /dev/null +++ b/docs/adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md @@ -0,0 +1,67 @@ +# ADR 039 — The A1 sidecar owns experiment provenance, not camera configuration + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Relates to:** ADR 020, ADR 022, ADR 027, ADR 034, ADR 037 + +## Context + +The host writes a camera configuration sidecar beside every RAW. A1 also copied +the complete snapshot, confirmed readback, ROI, mask and absolute/factory bias +codes into its own config sidecar and into recorder metadata. The copies were +not independent measurements and could disagree. At the same time, the A1 +sidecar did not identify the exact protocol file that produced the point, and +commanded and measured optical depth appeared under several overlapping names. + +The detector can also move. The historical PBS rejected-port geometry measures +`I_pd = I_tot - I_exc`. A detector behind a camera/emission-path beamsplitter +measures the local signal directly; applying the complement model there is a +scientific error. + +## Decision + +New A1 sidecars use `schema = "stage-a.a1.sidecar.v2"`. + +- `[protocol]` records name, optional author version, source filename, SHA-256, + archived copy, point index/count/label/role and requested axes/bias offsets. +- `[depth]` separates `analysis_a`, `commanded_a` and `measured_a`, with an + explicit `analysis_source`. +- `[photodiode]` records detector placement and splitter fraction. `I_tot` + remains only in the photodiode owner's artefact when rejected-port geometry + uses it; A1 does not copy it. +- `[sensor]` keeps only dynamic conditions: temperature, dead time, scene lux + and reading age. Camera configuration, ROI/mask and bias codes remain in the + host sidecar referenced by `[files].camera_config_sidecar`. +- The exact protocol source is copied once per content hash into the + measurement folder. The original path is not treated as durable provenance. + +`PhotodiodePlacementV1` distinguishes `rejected_port`, `camera_path` and +`emission_path`. Only `rejected_port` uses the learned full-extinction anchor. +Direct paths use a session-local lamp-off dark reading. They fail closed until +the operator explicitly captures or enters it. The calibration and artifacts +carry its value, source, ID, capture time, and age; a typed value is labelled +`manual` and is never confused with a measured lamp-off reference. `splitter_fraction` is +provenance and does not rescale log contrast. + +## Compatibility + +Old JSON control snapshots that omit placement decode as `rejected_port`, the +only geometry supported by those owners. Existing v1 A1 sidecars remain valid +input to offline analysis. Readers accept both layouts: + +| legacy v1 | v2 | +| --- | --- | +| `depth_a_source` | `depth.analysis_source` | +| `depth_a` | `depth.analysis_a` | +| `modulation.requested_a` / `sweep.commanded_a` | `depth.commanded_a` | +| `optical.measured_a` | `depth.measured_a` | +| `optical.*` | `photodiode.*` | + +The writer emits only v2. It does not retain duplicate deprecated fields. + +## Consequences + +The host camera sidecar is the single source of truth for camera configuration. +The A1 sidecar is the source of truth for schedule identity, optical provenance +and cross-file links. A direct-path measurement can no longer be blocked by or +silently corrected with an unrelated `I_tot` estimate. diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index 37e085e..39bc629 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -40,7 +40,10 @@ (firmware-qualified drive limits remain separate from A1 sample-density), [ADR 037](../adr/037-stage-a-a1-camera-configurations-and-bias-points.md) (protocols apply host camera profiles and per-point biases with readback and - restore). + restore), + [ADR 039](../adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md) + (A1 records protocol and optical provenance but does not duplicate the host + camera sidecar). - **Automation roadmap:** [Stage-A A1 Automation](./stage-a-a1-automation.md) - **Second workflow:** [Stage-A A1 Exact Event Count](./stage-a-a1-event-count.md) — hold one *measured* depth `a₀` across the frequency sweep @@ -62,6 +65,10 @@ drive remains owned by the modulation plugin and camera settings remain owned by the host; A1 retargets them only through declared, generic control capabilities. +Runtime requires Augur 2.0.2 or newer. Older installed hosts do not publish the +camera-session and sensor-monitoring contracts this workflow needs, even when +the plugin binary is current. + ## The recording workflow The experiment sweeps the modulation depth `a = ln(I_max/I_min)` at a fixed @@ -396,7 +403,7 @@ A1 applies the initial configuration and each point through the same generic complete-snapshot host command. For a bias point it clones the last confirmed snapshot and changes only `diff_on`/`diff_off`; no A1- or bias-specific command exists in the recorder. A1 checks its own scientific requirements (sensor -telemetry on, STC and Trail off) and starts recording automatically after the +telemetry on, STC, Trail and ERC explicitly off) and starts recording automatically after the host returns a fresh matching sensor readback. For manual recordings A1 never drives the Teensy: set the drive (high `a` for diff --git a/plugins/stage-a-a1/Cargo.toml b/plugins/stage-a-a1/Cargo.toml index 4795fbe..9e02ca3 100644 --- a/plugins/stage-a-a1/Cargo.toml +++ b/plugins/stage-a-a1/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"] augur-plugin-api.workspace = true serde.workspace = true serde_json.workspace = true +sha2 = "0.10" stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } toml = "0.8" diff --git a/plugins/stage-a-a1/README.md b/plugins/stage-a-a1/README.md index 92b47fa..597b95f 100644 --- a/plugins/stage-a-a1/README.md +++ b/plugins/stage-a-a1/README.md @@ -28,7 +28,8 @@ ladder, the response curve — reads as `a`: Open loop there is **nothing to search for**, so `Find a₀` is not used and the frequency ladder skips it (see below), and the photodiode's window-length and clipping checks are skipped because neither -bounds a commanded depth. Every artefact records the source: `depth_a_source`/`depth_a` in the sidecar, +bounds a commanded depth. Every artefact records the source: `depth.analysis_source` / +`depth.analysis_a` in the sidecar, `depth_source` in `[a0_lock]` and in `a0_locks.json`, and the *a from* column of the a₀ lock view. `measured_a` stays reserved for a number the photodiode actually measured. See [ADR 020](../../docs/adr/020-stage-a-a1-depth-source.md). @@ -141,6 +142,19 @@ Files share an `_` stem: `/_.raw` (camera, under the experiment directory to co-locate everything. The host also writes its own `.toml` next to the RAW with the camera biases/ROI; the A1 sidecar cross-references it. +The A1 sidecar uses `schema = "stage-a.a1.sidecar.v2"`. It does not copy the +host-owned camera snapshot, readback, ROI, mask or bias codes. The host TOML +named by `[files].camera_config_sidecar` is their single source of truth. +`[protocol]` identifies the experiment schedule by name, optional version, +source filename, SHA-256 and row identity. A content-addressed copy of the +protocol is archived in the measurement folder. `[depth]` keeps analysis, +commanded and measured `a` separate. `[photodiode]` records `rejected_port`, +`camera_path` or `emission_path` plus the splitter fraction. See +[ADR 039](../../docs/adr/039-stage-a-a1-sidecar-owns-experiment-provenance.md). + +This plugin requires Augur **2.0.2 or newer**. Older hosts do not publish the +camera-session and sensor-monitoring contracts this workflow depends on. + ## Protocol — run a survey from a file The four sweep buttons each move one axis and leave the others wherever they are. A **protocol** @@ -184,6 +198,9 @@ a complete measurement, not one that needs two button presses first. Kept for a dense regular sweep, which a 96-row CSV states badly: ```toml +name = "a1-example-survey" +version = "2026-08-13" + [camera] profile = "A1_low_noise" @@ -214,14 +231,17 @@ Stop, or abort. A rejected or timed-out restore is retried up to three times and is never reported as successful without confirmation. A named profile may enable Sensor reading in that same apply; A1 uses the confirmed host reply, not the previous UI state. Missing readback or a confirmed snapshot with Sensor -reading disabled fails closed. Sensor-specific bias ranges stay in the camera -backend. +reading disabled fails closed. Before running the qualified CSV files, save the +named profile `A1-bias-v1-monitoring` with `bias-v1`, the frozen ROI and pixel +mask, STC, Trail, and ERC explicitly OFF, and **Record sensor monitoring** ON. +Sensor-specific bias ranges stay in the camera backend. The host command always carries a complete camera snapshot. For a point, A1 clones the last confirmed snapshot and changes only `diff_on`/`diff_off`, so the remaining biases, ROI, mask, filters, trigger, and global settings stay explicit and unchanged. A1 itself rejects confirmed configurations with sensor telemetry -off or STC/Trail on; these experiment rules are not built into the recorder. +off, STC or Trail on, ERC on, or an ERC state omitted by an older host. A new +host must report ERC explicitly OFF; absence is not interpreted as OFF. The current firmware-qualified drive range is 0.01 Hz to 2 kHz. A1 also requires at least 16 photodiode samples per cycle: 1.25 kHz at 20 kSa/s and diff --git a/plugins/stage-a-a1/plugin.toml b/plugins/stage-a-a1/plugin.toml index f1a4021..32cfd84 100644 --- a/plugins/stage-a-a1/plugin.toml +++ b/plugins/stage-a-a1/plugin.toml @@ -5,7 +5,7 @@ description = "Stage-A A1 recording coordinator: one-button synchronized camera domain = "stage-a" library = "augur_plugin_stage_a_a1" phase = "raw_events" -min_augur_version = "1.0.0" +min_augur_version = "2.0.2" host_commands = [ "start_recording", "stop_recording", diff --git a/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv new file mode 100644 index 0000000..2e78779 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv @@ -0,0 +1,333 @@ +# A1 Drei-Fluss-Tiefensteigung — f_c proportional I Discriminator +# Zweck: den korrigierten Tiefensteigungs-Pol bei drei Flusswerten messen. +# Primaer: beta_p(f)=d E[N_p]/da aus vollstaendigen Triggerzyklen; Intercept frei. +# Piloten, Floors, Referenzen und verschiedene mean_u niemals als Grid poolen. +# +# Frequenzen: 5, 12.5, 25, 50, 100, 160, 220, 280, 350, 450, 640, 800 Hz. +# Sechs Tiefen je Frequenz: 0.15, 0.28, 0.45, 0.80, 1.30, 1.70. +# 220 Hz wird in beiden Paessen wiederholt und ist die Zustands-/Driftbruecke. +# Pass B kehrt Frequenz- und Flussrichtung um; Tiefenrichtung wechselt je Frequenz. +# 640/800 Hz sind Censoring-/Null-Guards und werden nur bei bestandener Linearitaet +# in einen Fit aufgenommen. Fitband vor dem Fit gemeinsam fuer alle Fluesse festlegen. +# +# VORHER: a1_lux_dark_offset.csv blockiert, danach a1_illuminated_smoke.csv +# beleuchtet und mit Depth a source = Photodiode measured erfolgreich fahren. +# Bias-v1, ROI, Probe, Optik, Kalibration und PD-Last bleiben ueber beide Paesse fest. +# Ausfuehrbare Aufbau-/Abbruchregeln: knowledge base/experiments/A1-bode/ +# next-flux-discriminator-protocol.md. +# +# Umfang: 297 Recordings, ~153 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# === a: Frequenzen [5.0, 25.0, 100.0, 220.0, 350.0, 640.0]; Flussfolge [0.15, 0.3, 0.45] +# +# --- a, mean_u=0.15 +A1-bias-v1-monitoring,floor_a,0.15,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.15,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.15,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.15,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.15,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.15,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +# +# --- a, mean_u=0.30 +A1-bias-v1-monitoring,floor_a,0.30,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.30,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.30,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.30,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.30,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.30,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +# +# --- a, mean_u=0.45 +A1-bias-v1-monitoring,floor_a,0.45,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.45,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.45,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.45,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.45,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.45,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +# +# === b: Frequenzen [800.0, 450.0, 280.0, 220.0, 160.0, 50.0, 12.5]; Flussfolge [0.45, 0.3, 0.15] +# +# --- b, mean_u=0.45 +A1-bias-v1-monitoring,floor_b,0.45,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.45,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +# +# --- b, mean_u=0.30 +A1-bias-v1-monitoring,floor_b,0.30,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.30,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +# +# --- b, mean_u=0.15 +A1-bias-v1-monitoring,floor_b,0.15,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.15,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, diff --git a/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv b/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv new file mode 100644 index 0000000..423a13e --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv @@ -0,0 +1,13 @@ +# A1 beleuchteter Smoke-Test — separat nach Lamp-off, vor dem Hauptlauf +# VOR START: Strahl kontrolliert freigeben; ATTO647-Fluoreszenz muss Kamera und +# Emissions-PD hinter Fluoreszenzfilter und 50:50-Strahlteiler erreichen. +# A1 Depth a source auf Photodiode measured stellen; dieselbe gemessene +# Lamp-off-Darkreferenz, Optik, Probe, ROI, Maske, Biases und PD-Last beibehalten. +# Stop: kein frisches measured_a, falsche Frequenz/Trigger, ADC-Clipping, fehlender +# positiver Dark-Headroom, PD-SNR < 10 oder Fehler in RAW/PDQ/Sensor-Sidecars. +# Der Punkt ist identisch zu einem Hauptgitterpunkt: mean_u=0.30, f=100 Hz, a=0.45. +# Er qualifiziert den Fluoreszenzpfad; er misst keine transmittierte Anregung. +# +# Umfang: 1 Recordings, ~1 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +A1-bias-v1-monitoring,illuminated_smoke,0.30,100,0.45,20,8, diff --git a/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv b/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv new file mode 100644 index 0000000..c490d11 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv @@ -0,0 +1,14 @@ +# A1 Lamp-off-Offset und H14-Sham — separat vor dem Hauptlauf fahren +# VOR START: Strahl physisch blockieren; Raumlicht und Kameraposition unveraendert. +# Diese Datei nie direkt mit dem Hauptprotokoll verketten: nach dem Recording stoppen, +# Dateien finalisieren, dann den Strahlengang kontrolliert wieder freigeben. +# Ausgabe: Median/Streuung illumination_lux, Kamera-Untergrund je Polaritaet und +# Darkwert der Emissions-PD hinter Filter/50:50. Der Offset gilt nur fuer diese Sitzung. +# Die kommandierte a=0.02-Modulation ist kein optischer Background; bei blockiertem +# Licht dient sie ausschliesslich als koharenter H14-Uebersprechtest. +# A1 Depth a source fuer diesen Lauf auf Commanded stellen. Vor dem beleuchteten +# Smoke-Test wieder auf Photodiode measured zurueckstellen. +# +# Umfang: 1 Recordings, ~1 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +A1-bias-v1-monitoring,lux_dark_h14,0.30,100,0.02,30,8,background diff --git a/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv b/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv new file mode 100644 index 0000000..866276a --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv @@ -0,0 +1,147 @@ +# A1 Stufe 1 — vollstaendige Bode-Kurve + DC-Aufhebungstest +# EINE Sitzung, EINE Intensitaetseinstellung, EINE durchgehende Kurve. +# +# (A) Bode-Kurve, 0.1 bis 68 Hz. Der Sweep vom 31.07. begann bei 1 Hz und lag +# dort schon im Abfall (f_c ~ 2-3 Hz) — ohne gemessenes Plateau sind |H| +# und C nur Obergrenzen. Der ganze Bereich wird neu gefahren, nicht nur +# das fehlende Stueck: |H| = N(f)/N_Plateau braucht Plateau UND Abfall auf +# derselben Kurve. Zwei Sitzungen zusammenzukleben ginge nur, wenn die +# Intensitaet exakt dieselbe waere — sie ist es nicht (siehe unten). +# Die hohen Frequenzen sind billig (20 s), das kostet nur ~15 min extra. +# Oberhalb 68 Hz war zuletzt |H| < 0.03, deshalb endet die Leiter dort. +# +# (B) DC-Aufhebung. Bei festem a muss die Eventzahl pro Zyklus bei tiefem f +# unabhaengig von der Helligkeit sein ('I_0 verschiebt das Knie, a setzt +# die Hoehe'). Die Methodik nennt das den schaerfsten Test der Stufe A. +# Faellt er durch, ist die Log-Frontend-Annahme kaputt. Drei +# Arbeitspunkte, gleiches a, gleiches f. +# +# ZWINGEND VOR DEM START — sonst laeuft kein einziges Recording durch. +# Der Lauf vom 03.08. ist an genau diesen drei Punkten gescheitert (0/73): +# +# 1. I_tot-ANKER. stage-a-a1 verweigert den Sidecar ohne ihn: 'cannot write +# a quantitative A1 sidecar without a fresh photodiode optical summary +# from a confirmed I_tot anchor'. Das ist eine Software-Vorbedingung, +# nicht eine Anforderung der Auswertung — der Sidecar gilt sonst als +# nicht quantitativ. Also: Pockels auf volle Anregungsextinktion fahren, +# PD-Maximum als I_tot bestaetigen. Nach der Sitzung wiederholen, die +# Differenz ist das Driftbudget. +# +# 2. AUTOMATION LEASE fuer Modulations- UND Photodioden-Owner. Ohne aktive +# Lease lehnt jeder Retarget ab ('the modulation owner requires an +# active automation lease'). Sie laeuft ab — bei 60 min Sitzung darauf +# achten, dass die Gueltigkeit reicht. +# +# 3. LOBE-DECKE. stage-a-modulation rechnet +# u_g = mean_u / I_0(a/2) (geometrischer Pedestal) +# u_peak = u_g * exp(a/2) und verlangt u_peak <= 1, +# weil die Transmission oberhalb der Lobe-Spitze nicht mehr monoton ist. +# Dieses Protokoll haelt das fuer jede Zeile ein. ABER: steht in der +# Oberflaeche ein groesseres Sweep-Maximum a als in dieser Datei, wird +# DAS geprueft. Der 03.08. meldete u_peak = 1.222 bei mean_u = 0.400 — +# das entspricht a ~ 3.6 und stammt NICHT aus dieser Datei (max 1.70). +# Also Sweep-Maximum in der Oberflaeche auf 1.70 setzen. Zur Kontrolle, +# groesstes zulaessiges mean_u = I_0(a/2) * exp(-a/2): +# a = 1.70 -> 0.508 a = 3.00 -> 0.367 a = 5.50 -> 0.255 +# +# Was trotzdem ins Logbuch gehoert: die eingestellten AOM-/Laserwerte und der +# Lux-Wert. Lux taugt nicht als Flussachse (ueber 100 Recordings bei festem I_k +# streut er 6.5 % gegen 1.8 % beim PD-Mittel und korreliert mit dem optischen +# Pegel nur zu r = +0.10), aber als grober Wiederfindungshinweis und +# Raumlicht-Waechter kostet er nichts. +# +# Der 31.07.-Datensatz wird durch diese Sitzung ersetzt, nicht ergaenzt. +# +# Umfang: 73 Recordings, ~61 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# === Teil A: Bode-Kurve 0.1 - 68 Hz bei mean_u = 0.30 =================== +# Bei 0.10/0.20 Hz nur 4 Amplituden — dort kostet ein Punkt bis 2 min. +# Ab 0.40 Hz sechs Amplituden, dicht um den Antwortuebergang a_50 ~ 0.23. +# +# --- Flusspunkt mean_u = 0.30 ---------------------------------------- +floor,0.30,2,0.02,20,8,background +windows,0.30,0.1,1.70,120,3,pilot +windows,0.30,1,1.70,20,3,pilot +windows,0.30,68.219,1.70,20,3,pilot +plateau,0.30,0.1,0.15,120,3, +plateau,0.30,0.1,0.28,120,3, +plateau,0.30,0.1,0.80,120,3, +plateau,0.30,0.1,1.70,120,3, +plateau,0.30,0.2,1.70,100,3, +plateau,0.30,0.2,0.80,100,3, +plateau,0.30,0.2,0.28,100,3, +plateau,0.30,0.2,0.15,100,3, +plateau,0.30,0.4,0.15,50,3, +plateau,0.30,0.4,0.28,50,3, +plateau,0.30,0.4,0.45,50,3, +plateau,0.30,0.4,0.80,50,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,0.4,1.30,50,3, +plateau,0.30,0.4,1.70,50,3, +plateau,0.30,0.7,1.70,29,3, +plateau,0.30,0.7,1.30,29,3, +plateau,0.30,0.7,0.80,29,3, +plateau,0.30,0.7,0.45,29,3, +plateau,0.30,0.7,0.28,29,3, +plateau,0.30,0.7,0.15,29,3, +plateau,0.30,1,0.15,20,3, +plateau,0.30,1,0.28,20,3, +plateau,0.30,1,0.45,20,3, +plateau,0.30,1,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,1,1.30,20,3, +plateau,0.30,1,1.70,20,3, +plateau,0.30,2,1.70,20,3, +plateau,0.30,2,1.30,20,3, +plateau,0.30,2,0.80,20,3, +plateau,0.30,2,0.45,20,3, +plateau,0.30,2,0.28,20,3, +plateau,0.30,2,0.15,20,3, +plateau,0.30,5.415,0.15,20,3, +plateau,0.30,5.415,0.28,20,3, +plateau,0.30,5.415,0.45,20,3, +plateau,0.30,5.415,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,5.415,1.30,20,3, +plateau,0.30,5.415,1.70,20,3, +plateau,0.30,12.599,1.70,20,3, +plateau,0.30,12.599,1.30,20,3, +plateau,0.30,12.599,0.80,20,3, +plateau,0.30,12.599,0.45,20,3, +plateau,0.30,12.599,0.28,20,3, +plateau,0.30,12.599,0.15,20,3, +plateau,0.30,29.317,0.15,20,3, +plateau,0.30,29.317,0.28,20,3, +plateau,0.30,29.317,0.45,20,3, +plateau,0.30,29.317,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,29.317,1.30,20,3, +plateau,0.30,29.317,1.70,20,3, +plateau,0.30,68.219,1.70,20,3, +plateau,0.30,68.219,1.30,20,3, +plateau,0.30,68.219,0.80,20,3, +plateau,0.30,68.219,0.45,20,3, +plateau,0.30,68.219,0.28,20,3, +plateau,0.30,68.219,0.15,20,3, +# +# === Teil B: DC-Aufhebungstest ========================================== +# f = 0.4 Hz, a = [0.45, 1.3] — beides schon in Teil A gefahren, deshalb ist +# der Arm mean_u = 0.30 dort bereits enthalten und wird hier nicht wiederholt. +# Erwartung: gleiche Events/Zyklus/Pixel bei gleichem a, unabhaengig von der +# Helligkeit. Abweichung > 10 % ist ein Befund, kein Rauschen. +# +# --- Flusspunkt mean_u = 0.15 ---------------------------------------- +floor,0.15,0.4,0.02,50,8,background +windows,0.15,0.4,1.70,50,3,pilot +dc,0.15,0.4,0.45,50,3, +dc,0.15,0.4,1.30,50,3, +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,0.4,0.02,50,8,background +windows,0.45,0.4,1.70,50,3,pilot +dc,0.45,0.4,0.45,50,3, +dc,0.45,0.4,1.30,50,3, +# +# Abschluss: Referenz zurueck auf den Startpunkt (Drift ueber die Sitzung). +ref,0.30,0.5,0.80,40,8, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv new file mode 100644 index 0000000..87824ac --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv @@ -0,0 +1,83 @@ +# A1 Stufe 2 — vollstaendiger Flusspunkt mean_u=0.10 +# Ein unabhaengig fahrbarer Flusspunkt fuer f_c(I), C(I) und ON/OFF. +# Die heutige 0.30-Kurve ersetzt die tiefen Frequenzen hier NICHT: |H(f)| +# wird bei demselben I_k durch das lokale Plateau normiert, und f_c verschiebt +# sich mit I_k. Deshalb bleiben zwei lokale Plateaupunkte erhalten; die dichte +# Niederfrequenzsuche aus Stufe 1 wird aber nicht wiederholt. +# +# Aktueller Prior: f_c(0.10) ~ 0.725 Hz, skaliert aus +# f_c(0.40)=2.9 Hz. Nach dem heutigen 0.30-Quicklook neu zentrieren, +# falls gemessenes f_c oder die realisierten I_k-Verhaeltnisse deutlich abweichen. +# +# Umfang je Punkt: sieben Frequenzen; an den zwei tiefen Frequenzen fuenf +# a-Werte (0.15, 0.28, 0.45, 0.80, 1.70), sonst alle sechs bis 1.70. +# Die eigentlichen Messpunkte enthalten 20 Zyklen (maximal 300 s); Pilot, +# Background und lokale Plateau-Referenzen sind separat enthalten. +# +# Laserleistung, ND, Bias, Probe/ROI und Ausrichtung innerhalb des Blocks fest +# halten. mean_u ist nur der Setpoint; ausgewertet wird das gemessene lokale I_k. +# Vor/nach dem Block ADC-Dark und I_tot ankern; RAW, PDQ, beide JSON/TOML- +# Sidecars und den ausgefuehrten Zeitplan pruefen. Automations-Leases muessen +# fuer die gesamte Laufzeit gelten; GUI-Sweep-Maximum vor Start auf a=1.70. +# Photodioden-Cache: nichts einzustellen. Der Ring waechst selbst auf die +# gemessene Markerperiode, am tiefsten Punkt also auf die zwei vollen Zyklen, +# die die drei Phasenmarker brauchen. Grenze bleibt der harte 32-s-Ring bei +# 500 kSa/s (ADR 033). +# +# Umfang: 47 Recordings, ~68 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.10 erwartetes f_c ~ 0.72 Hz Leiter 0.075 .. 8.7 Hz +# Lokales Plateau: 0.075 und 0.145 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.10 ---------------------------------------- +floor,0.10,0.725,0.02,28,8,background +windows,0.10,0.075,1.70,120,3,pilot +windows,0.10,8.7,1.70,20,3,pilot +ladder,0.10,0.075,0.15,267,3, +ladder,0.10,0.075,0.28,267,3, +ladder,0.10,0.075,0.45,267,3, +ladder,0.10,0.075,0.80,267,3, +ladder,0.10,0.075,1.70,267,3, +ladder,0.10,0.145,1.70,138,3, +ladder,0.10,0.145,0.80,138,3, +ladder,0.10,0.145,0.45,138,3, +ladder,0.10,0.145,0.28,138,3, +ladder,0.10,0.145,0.15,138,3, +ladder,0.10,0.362,0.15,56,3, +ladder,0.10,0.362,0.28,56,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,0.362,0.45,56,3, +ladder,0.10,0.362,0.80,56,3, +ladder,0.10,0.362,1.30,56,3, +ladder,0.10,0.362,1.70,56,3, +ladder,0.10,0.725,1.70,28,3, +ladder,0.10,0.725,1.30,28,3, +ladder,0.10,0.725,0.80,28,3, +ladder,0.10,0.725,0.45,28,3, +ladder,0.10,0.725,0.28,28,3, +ladder,0.10,0.725,0.15,28,3, +ladder,0.10,1.595,0.15,20,3, +ladder,0.10,1.595,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,1.595,0.45,20,3, +ladder,0.10,1.595,0.80,20,3, +ladder,0.10,1.595,1.30,20,3, +ladder,0.10,1.595,1.70,20,3, +ladder,0.10,3.625,1.70,20,3, +ladder,0.10,3.625,1.30,20,3, +ladder,0.10,3.625,0.80,20,3, +ladder,0.10,3.625,0.45,20,3, +ladder,0.10,3.625,0.28,20,3, +ladder,0.10,3.625,0.15,20,3, +ladder,0.10,8.7,0.15,20,3, +ladder,0.10,8.7,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,8.7,0.45,20,3, +ladder,0.10,8.7,0.80,20,3, +ladder,0.10,8.7,1.30,20,3, +ladder,0.10,8.7,1.70,20,3, +# +# Abschluss: lokale Plateau-Referenz bei 0.145 Hz, a=0.8. +ref,0.10,0.145,0.80,120,3, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv new file mode 100644 index 0000000..a57c062 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv @@ -0,0 +1,83 @@ +# A1 Stufe 2 — vollstaendiger Flusspunkt mean_u=0.45 +# Ein unabhaengig fahrbarer Flusspunkt fuer f_c(I), C(I) und ON/OFF. +# Die heutige 0.30-Kurve ersetzt die tiefen Frequenzen hier NICHT: |H(f)| +# wird bei demselben I_k durch das lokale Plateau normiert, und f_c verschiebt +# sich mit I_k. Deshalb bleiben zwei lokale Plateaupunkte erhalten; die dichte +# Niederfrequenzsuche aus Stufe 1 wird aber nicht wiederholt. +# +# Aktueller Prior: f_c(0.45) ~ 3.262 Hz, skaliert aus +# f_c(0.40)=2.9 Hz. Nach dem heutigen 0.30-Quicklook neu zentrieren, +# falls gemessenes f_c oder die realisierten I_k-Verhaeltnisse deutlich abweichen. +# +# Umfang je Punkt: sieben Frequenzen; an den zwei tiefen Frequenzen fuenf +# a-Werte (0.15, 0.28, 0.45, 0.80, 1.70), sonst alle sechs bis 1.70. +# Die eigentlichen Messpunkte enthalten 20 Zyklen (maximal 300 s); Pilot, +# Background und lokale Plateau-Referenzen sind separat enthalten. +# +# Laserleistung, ND, Bias, Probe/ROI und Ausrichtung innerhalb des Blocks fest +# halten. mean_u ist nur der Setpoint; ausgewertet wird das gemessene lokale I_k. +# Vor/nach dem Block ADC-Dark und I_tot ankern; RAW, PDQ, beide JSON/TOML- +# Sidecars und den ausgefuehrten Zeitplan pruefen. Automations-Leases muessen +# fuer die gesamte Laufzeit gelten; GUI-Sweep-Maximum vor Start auf a=1.70. +# Photodioden-Cache: nichts einzustellen. Der Ring waechst selbst auf die +# gemessene Markerperiode, am tiefsten Punkt also auf die zwei vollen Zyklen, +# die die drei Phasenmarker brauchen. Grenze bleibt der harte 32-s-Ring bei +# 500 kSa/s (ADR 033). +# +# Umfang: 47 Recordings, ~32 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.45 erwartetes f_c ~ 3.26 Hz Leiter 0.261 .. 39.15 Hz +# Lokales Plateau: 0.261 und 0.652 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,3.262,0.02,20,8,background +windows,0.45,0.261,1.70,77,3,pilot +windows,0.45,39.15,1.70,20,3,pilot +ladder,0.45,0.261,0.15,77,3, +ladder,0.45,0.261,0.28,77,3, +ladder,0.45,0.261,0.45,77,3, +ladder,0.45,0.261,0.80,77,3, +ladder,0.45,0.261,1.70,77,3, +ladder,0.45,0.652,1.70,31,3, +ladder,0.45,0.652,0.80,31,3, +ladder,0.45,0.652,0.45,31,3, +ladder,0.45,0.652,0.28,31,3, +ladder,0.45,0.652,0.15,31,3, +ladder,0.45,1.631,0.15,20,3, +ladder,0.45,1.631,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,1.631,0.45,20,3, +ladder,0.45,1.631,0.80,20,3, +ladder,0.45,1.631,1.30,20,3, +ladder,0.45,1.631,1.70,20,3, +ladder,0.45,3.262,1.70,20,3, +ladder,0.45,3.262,1.30,20,3, +ladder,0.45,3.262,0.80,20,3, +ladder,0.45,3.262,0.45,20,3, +ladder,0.45,3.262,0.28,20,3, +ladder,0.45,3.262,0.15,20,3, +ladder,0.45,7.178,0.15,20,3, +ladder,0.45,7.178,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,7.178,0.45,20,3, +ladder,0.45,7.178,0.80,20,3, +ladder,0.45,7.178,1.30,20,3, +ladder,0.45,7.178,1.70,20,3, +ladder,0.45,16.312,1.70,20,3, +ladder,0.45,16.312,1.30,20,3, +ladder,0.45,16.312,0.80,20,3, +ladder,0.45,16.312,0.45,20,3, +ladder,0.45,16.312,0.28,20,3, +ladder,0.45,16.312,0.15,20,3, +ladder,0.45,39.15,0.15,20,3, +ladder,0.45,39.15,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,39.15,0.45,20,3, +ladder,0.45,39.15,0.80,20,3, +ladder,0.45,39.15,1.30,20,3, +ladder,0.45,39.15,1.70,20,3, +# +# Abschluss: lokale Plateau-Referenz bei 0.652 Hz, a=0.8. +ref,0.45,0.652,0.80,31,3, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv b/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv new file mode 100644 index 0000000..ed60579 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv @@ -0,0 +1,286 @@ +# A1 Stufe 2 — Flussleiter: f_c(I), C(I) und die ON/OFF-Asymmetrie +# Zweck: das eigentliche A1-Ergebnis. f_c an mehreren Arbeitspunkten, um +# 'f_c ~ I' zu pruefen — das Akzeptanzkriterium, an dem sich entscheidet, ob die +# Flussachse stimmt (Kruemmung deutet auf einen Kalibrierfehler, nicht auf neue +# Pixelphysik; dann geht es zu A6). +# +# ERST FAHREN, WENN STUFE 1 BESTANDEN IST. Ohne Plateau ist jedes f_c hier +# wieder nur eine Obergrenze, und ohne bestandenen DC-Test ist die Normierung +# nicht gerechtfertigt. +# +# Frequenzleiter je Arbeitspunkt: etwa 0.08 bis 12 x das ERWARTETE f_c, +# geometrisch. Beim dunkelsten Punkt wird f_min auf 0.075 Hz angehoben, weil +# die urspruenglichen 0.058 Hz bei 500 kSa/s nicht in den 32-s-Cache passen. +# Die Erwartung skaliert linear aus dem Messwert f_c(0.40) = 2.9 Hz — genau die +# Annahme, die geprueft wird. Liegt f_c weit daneben, sitzt die Leiter schief; +# nach dem ersten Arbeitspunkt kurz kontrollieren und ggf. neu generieren. +# +# Oberhalb ~20 x f_c war bei der Vorsitzung keine Antwort mehr messbar +# (|H| < 0.03), deshalb endet die Leiter dort statt bei 2 kHz. +# +# Umfang: 231 Recordings, ~224 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.10 erwartetes f_c ~ 0.72 Hz Leiter 0.075 .. 8.7 Hz +# Lokales Plateau: 0.075 und 0.145 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.10 ---------------------------------------- +floor,0.10,0.725,0.02,28,8,background +windows,0.10,0.075,1.70,120,3,pilot +windows,0.10,8.7,1.70,20,3,pilot +ladder,0.10,0.075,0.15,267,3, +ladder,0.10,0.075,0.28,267,3, +ladder,0.10,0.075,0.45,267,3, +ladder,0.10,0.075,0.80,267,3, +ladder,0.10,0.075,1.70,267,3, +ladder,0.10,0.145,1.70,138,3, +ladder,0.10,0.145,0.80,138,3, +ladder,0.10,0.145,0.45,138,3, +ladder,0.10,0.145,0.28,138,3, +ladder,0.10,0.145,0.15,138,3, +ladder,0.10,0.362,0.15,56,3, +ladder,0.10,0.362,0.28,56,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,0.362,0.45,56,3, +ladder,0.10,0.362,0.80,56,3, +ladder,0.10,0.362,1.30,56,3, +ladder,0.10,0.362,1.70,56,3, +ladder,0.10,0.725,1.70,28,3, +ladder,0.10,0.725,1.30,28,3, +ladder,0.10,0.725,0.80,28,3, +ladder,0.10,0.725,0.45,28,3, +ladder,0.10,0.725,0.28,28,3, +ladder,0.10,0.725,0.15,28,3, +ladder,0.10,1.595,0.15,20,3, +ladder,0.10,1.595,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,1.595,0.45,20,3, +ladder,0.10,1.595,0.80,20,3, +ladder,0.10,1.595,1.30,20,3, +ladder,0.10,1.595,1.70,20,3, +ladder,0.10,3.625,1.70,20,3, +ladder,0.10,3.625,1.30,20,3, +ladder,0.10,3.625,0.80,20,3, +ladder,0.10,3.625,0.45,20,3, +ladder,0.10,3.625,0.28,20,3, +ladder,0.10,3.625,0.15,20,3, +ladder,0.10,8.7,0.15,20,3, +ladder,0.10,8.7,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,8.7,0.45,20,3, +ladder,0.10,8.7,0.80,20,3, +ladder,0.10,8.7,1.30,20,3, +ladder,0.10,8.7,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.17 erwartetes f_c ~ 1.23 Hz Leiter 0.099 .. 14.79 Hz +# Lokales Plateau: 0.099 und 0.246 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.17 ---------------------------------------- +floor,0.17,1.232,0.02,20,8,background +windows,0.17,0.099,1.70,120,3,pilot +windows,0.17,14.79,1.70,20,3,pilot +ladder,0.17,0.099,0.15,203,3, +ladder,0.17,0.099,0.28,203,3, +ladder,0.17,0.099,0.45,203,3, +ladder,0.17,0.099,0.80,203,3, +ladder,0.17,0.099,1.70,203,3, +ladder,0.17,0.246,1.70,82,3, +ladder,0.17,0.246,0.80,82,3, +ladder,0.17,0.246,0.45,82,3, +ladder,0.17,0.246,0.28,82,3, +ladder,0.17,0.246,0.15,82,3, +ladder,0.17,0.616,0.15,33,3, +ladder,0.17,0.616,0.28,33,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,0.616,0.45,33,3, +ladder,0.17,0.616,0.80,33,3, +ladder,0.17,0.616,1.30,33,3, +ladder,0.17,0.616,1.70,33,3, +ladder,0.17,1.232,1.70,20,3, +ladder,0.17,1.232,1.30,20,3, +ladder,0.17,1.232,0.80,20,3, +ladder,0.17,1.232,0.45,20,3, +ladder,0.17,1.232,0.28,20,3, +ladder,0.17,1.232,0.15,20,3, +ladder,0.17,2.712,0.15,20,3, +ladder,0.17,2.712,0.28,20,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,2.712,0.45,20,3, +ladder,0.17,2.712,0.80,20,3, +ladder,0.17,2.712,1.30,20,3, +ladder,0.17,2.712,1.70,20,3, +ladder,0.17,6.162,1.70,20,3, +ladder,0.17,6.162,1.30,20,3, +ladder,0.17,6.162,0.80,20,3, +ladder,0.17,6.162,0.45,20,3, +ladder,0.17,6.162,0.28,20,3, +ladder,0.17,6.162,0.15,20,3, +ladder,0.17,14.79,0.15,20,3, +ladder,0.17,14.79,0.28,20,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,14.79,0.45,20,3, +ladder,0.17,14.79,0.80,20,3, +ladder,0.17,14.79,1.30,20,3, +ladder,0.17,14.79,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.25 erwartetes f_c ~ 1.81 Hz Leiter 0.145 .. 21.75 Hz +# Lokales Plateau: 0.145 und 0.362 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.25 ---------------------------------------- +floor,0.25,1.812,0.02,20,8,background +windows,0.25,0.145,1.70,120,3,pilot +windows,0.25,21.75,1.70,20,3,pilot +ladder,0.25,0.145,0.15,138,3, +ladder,0.25,0.145,0.28,138,3, +ladder,0.25,0.145,0.45,138,3, +ladder,0.25,0.145,0.80,138,3, +ladder,0.25,0.145,1.70,138,3, +ladder,0.25,0.362,1.70,56,3, +ladder,0.25,0.362,0.80,56,3, +ladder,0.25,0.362,0.45,56,3, +ladder,0.25,0.362,0.28,56,3, +ladder,0.25,0.362,0.15,56,3, +ladder,0.25,0.906,0.15,23,3, +ladder,0.25,0.906,0.28,23,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,0.906,0.45,23,3, +ladder,0.25,0.906,0.80,23,3, +ladder,0.25,0.906,1.30,23,3, +ladder,0.25,0.906,1.70,23,3, +ladder,0.25,1.812,1.70,20,3, +ladder,0.25,1.812,1.30,20,3, +ladder,0.25,1.812,0.80,20,3, +ladder,0.25,1.812,0.45,20,3, +ladder,0.25,1.812,0.28,20,3, +ladder,0.25,1.812,0.15,20,3, +ladder,0.25,3.987,0.15,20,3, +ladder,0.25,3.987,0.28,20,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,3.987,0.45,20,3, +ladder,0.25,3.987,0.80,20,3, +ladder,0.25,3.987,1.30,20,3, +ladder,0.25,3.987,1.70,20,3, +ladder,0.25,9.062,1.70,20,3, +ladder,0.25,9.062,1.30,20,3, +ladder,0.25,9.062,0.80,20,3, +ladder,0.25,9.062,0.45,20,3, +ladder,0.25,9.062,0.28,20,3, +ladder,0.25,9.062,0.15,20,3, +ladder,0.25,21.75,0.15,20,3, +ladder,0.25,21.75,0.28,20,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,21.75,0.45,20,3, +ladder,0.25,21.75,0.80,20,3, +ladder,0.25,21.75,1.30,20,3, +ladder,0.25,21.75,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.33 erwartetes f_c ~ 2.39 Hz Leiter 0.191 .. 28.71 Hz +# Lokales Plateau: 0.191 und 0.478 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.33 ---------------------------------------- +floor,0.33,2.392,0.02,20,8,background +windows,0.33,0.191,1.70,105,3,pilot +windows,0.33,28.71,1.70,20,3,pilot +ladder,0.33,0.191,0.15,105,3, +ladder,0.33,0.191,0.28,105,3, +ladder,0.33,0.191,0.45,105,3, +ladder,0.33,0.191,0.80,105,3, +ladder,0.33,0.191,1.70,105,3, +ladder,0.33,0.478,1.70,42,3, +ladder,0.33,0.478,0.80,42,3, +ladder,0.33,0.478,0.45,42,3, +ladder,0.33,0.478,0.28,42,3, +ladder,0.33,0.478,0.15,42,3, +ladder,0.33,1.196,0.15,20,3, +ladder,0.33,1.196,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,1.196,0.45,20,3, +ladder,0.33,1.196,0.80,20,3, +ladder,0.33,1.196,1.30,20,3, +ladder,0.33,1.196,1.70,20,3, +ladder,0.33,2.392,1.70,20,3, +ladder,0.33,2.392,1.30,20,3, +ladder,0.33,2.392,0.80,20,3, +ladder,0.33,2.392,0.45,20,3, +ladder,0.33,2.392,0.28,20,3, +ladder,0.33,2.392,0.15,20,3, +ladder,0.33,5.263,0.15,20,3, +ladder,0.33,5.263,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,5.263,0.45,20,3, +ladder,0.33,5.263,0.80,20,3, +ladder,0.33,5.263,1.30,20,3, +ladder,0.33,5.263,1.70,20,3, +ladder,0.33,11.962,1.70,20,3, +ladder,0.33,11.962,1.30,20,3, +ladder,0.33,11.962,0.80,20,3, +ladder,0.33,11.962,0.45,20,3, +ladder,0.33,11.962,0.28,20,3, +ladder,0.33,11.962,0.15,20,3, +ladder,0.33,28.71,0.15,20,3, +ladder,0.33,28.71,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,28.71,0.45,20,3, +ladder,0.33,28.71,0.80,20,3, +ladder,0.33,28.71,1.30,20,3, +ladder,0.33,28.71,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.45 erwartetes f_c ~ 3.26 Hz Leiter 0.261 .. 39.15 Hz +# Lokales Plateau: 0.261 und 0.652 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,3.262,0.02,20,8,background +windows,0.45,0.261,1.70,77,3,pilot +windows,0.45,39.15,1.70,20,3,pilot +ladder,0.45,0.261,0.15,77,3, +ladder,0.45,0.261,0.28,77,3, +ladder,0.45,0.261,0.45,77,3, +ladder,0.45,0.261,0.80,77,3, +ladder,0.45,0.261,1.70,77,3, +ladder,0.45,0.652,1.70,31,3, +ladder,0.45,0.652,0.80,31,3, +ladder,0.45,0.652,0.45,31,3, +ladder,0.45,0.652,0.28,31,3, +ladder,0.45,0.652,0.15,31,3, +ladder,0.45,1.631,0.15,20,3, +ladder,0.45,1.631,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,1.631,0.45,20,3, +ladder,0.45,1.631,0.80,20,3, +ladder,0.45,1.631,1.30,20,3, +ladder,0.45,1.631,1.70,20,3, +ladder,0.45,3.262,1.70,20,3, +ladder,0.45,3.262,1.30,20,3, +ladder,0.45,3.262,0.80,20,3, +ladder,0.45,3.262,0.45,20,3, +ladder,0.45,3.262,0.28,20,3, +ladder,0.45,3.262,0.15,20,3, +ladder,0.45,7.178,0.15,20,3, +ladder,0.45,7.178,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,7.178,0.45,20,3, +ladder,0.45,7.178,0.80,20,3, +ladder,0.45,7.178,1.30,20,3, +ladder,0.45,7.178,1.70,20,3, +ladder,0.45,16.312,1.70,20,3, +ladder,0.45,16.312,1.30,20,3, +ladder,0.45,16.312,0.80,20,3, +ladder,0.45,16.312,0.45,20,3, +ladder,0.45,16.312,0.28,20,3, +ladder,0.45,16.312,0.15,20,3, +ladder,0.45,39.15,0.15,20,3, +ladder,0.45,39.15,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,39.15,0.45,20,3, +ladder,0.45,39.15,0.80,20,3, +ladder,0.45,39.15,1.30,20,3, +ladder,0.45,39.15,1.70,20,3, +# +# Abschluss: Rueckkehr zur lokalen Plateau-Referenz des ersten/dunkelsten +# Flusspunkts. Der Vergleich mit ihrer ersten Wiederholung ist der Driftbefund. +ref,0.10,0.145,0.80,120,8, diff --git a/plugins/stage-a-a1/protocols/a1_triage_90min.csv b/plugins/stage-a-a1/protocols/a1_triage_90min.csv new file mode 100644 index 0000000..e95e634 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_triage_90min.csv @@ -0,0 +1,103 @@ +# A1 90-minute triage block — mirrored scout on the bleaching fluorescent sample +# Optical path: stage-a-fluorescence-dualcam-v1; transfer_scope=fluorescence_chain. +# This is A1: J24 phase-0 marker, NOT the A2 photodiode-comparator trigger. +# Runbook: knowledge base/experiments/A1-bode/checklist.md -> "90-minute triage block". +# Design: knowledge base/methodology/a1-bleaching-dual-camera.md +# +# Not generated by build_a1_protocols.py, but VALIDATED as a versioned plugin fixture +# (2026-08-10): shipped as plugins/stage-a-a1/protocols/a1_triage_90min.csv and checked by +# stage-a-modulation and stage-a-photodiode against the production parser, the recorded +# 2026-07-30 lobe/DAC ceiling, the exact mean->frequency->depth retarget order through the +# real service/drive_command path, and the production 500 kSa/s photodiode ring. +# 235/235 tests pass across the three Stage-A suites; the guards were confirmed live by +# mutation — an out-of-range mean_u and a one-cycle row both make the suites fail. +# This file and the shipped fixture must stay byte-identical; a test enforces it. +# +# Hand-checked in addition, because the suites do NOT check it: worst H7 is 200 Hz/a=1.50 +# -> 2fa/C = 2609 1/s against 1/tau_refr = 54945 1/s (provisional C=0.23, tau_refr=18.2 us). +# Recheck H7 against the actual bias readback before arming. +# The photodiode ring caps at 16e6 samples, so at 500 kSa/s the lowest frequency that can +# retain two cycles is 0.0625 Hz. The 0.2 Hz floor below keeps 3.2x margin. +# a=1.70 is the GUI limit, not a service limit — the modulation service accepts more at +# mean_u=0.30. Do not raise it here without re-deriving the H7 grid. +# +# OPERATOR STEPS THAT ARE NOT ROWS IN THIS FILE — do them first: +# 1. Freeze session: bias readback + name bias-vN, read bias_refr, ERC/STC/trail OFF read back, +# sample_id, FOV position/orientation, ROI ~256x256, disk, PD cache >= 30 s. +# 2. ADC dark, light-blocked H14 DAC-active sham, fresh I_tot anchor. The A1 plugin refuses +# the sidecar without a confirmed anchor. Repeat ADC dark and I_tot after the block. +# 3. sCMOS: confocality check, one dark-corrected flat map, freeze the flattest ROI. +# Do NOT flip or refocus between that map and windows_slow below — that pair is the +# offline sCMOS->event registration. +# 4. sCMOS constant-illumination bleach/pre-bleach run at the SAME CYCLE-MEAN flux as these +# rows (not the same mean_u — Bessel I0(a/2)). Stop at <1 % change over 2 min, or 8 min. +# 5. Compute k, then T_block <= 0.05/k and f_min = k/(0.04*0.30). THE LADDER BELOW STARTS AT +# 0.2 Hz. If f_min > 0.2 Hz, delete every row below f_min from BOTH passes symmetrically +# and record that you did. Do not run a frequency below f_min: the within-cycle bleaching +# ramp fakes an OFF excess of k/(2af) there. +# 6. Shutter-closed EVB dark recording (no row here — the camera sees no light). +# 7. After the block: closing sCMOS map + 2 min bleach bracket, closing ADC dark and I_tot. +# +# STRUCTURE. Two mirrored passes over 7 half-decade frequencies x 2 depths. Pass B is the +# EXACT reverse of pass A, so every frequency carries an early and a late acquisition and the +# drift bracket exists at the knee without knowing where the knee is. IF TIME RUNS SHORT, +# DELETE FREQUENCIES FROM BOTH PASSES — NEVER DROP PASS B. A single unbracketed pass +# reproduces the 2026-08-05 failure mode. +# +# DEPTHS 0.30 and 1.50 are far apart on purpose. The bleaching contribution to the ON/OFF +# asymmetry is k/(2af): it must fall as 1/f along the ladder AND as 1/a between the two +# depths, at the magnitude the measured k predicts. That scaling is the discriminator between +# a bleaching artefact and a real ON/OFF bandwidth difference. Optional third depth a=0.70 +# only if pass A finishes early — and then interleaved into BOTH passes, never appended. +# +# REFERENCES are 0.63 Hz / a=1.50. The three identical opening repeats define sigma_ref; +# every A/B difference is judged against it, not against a percentage. +# +# Budget: 40 recordings, 21.9 min acquisition, 2.2 min declared settling, 24.1 min total. +# Verified: pass B is the exact reverse of pass A, and the 7x2 grid is complete with no repeats. +# +# Analysis: background-subtracted events per cycle per valid pixel, ON and OFF never pooled, +# hot pixel (542,299) masked at event level if inside the ROI. The folded-histogram +# fundamental A1 is a DETECTION statistic only — it is shape-biased (2026-07-31). +# +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +floor_slow,0.3,0.63,0.02,60,8,background +floor_fast,0.3,63,0.02,20,3,background +windows_slow,0.3,0.63,1.7,32,3,pilot +windows_fast,0.3,63,1.5,20,3,pilot +ref_open_1,0.3,0.63,1.5,32,3, +ref_open_2,0.3,0.63,1.5,32,3, +ref_open_3,0.3,0.63,1.5,32,3, +scoutA,0.3,2.0,1.5,20,3, +scoutA,0.3,0.2,0.3,100,3, +scoutA,0.3,20,0.3,20,3, +scoutA,0.3,0.63,1.5,32,3, +scoutA,0.3,200,1.5,20,3, +ref_a1,0.3,0.63,1.5,32,3, +scoutA,0.3,6.3,0.3,20,3, +scoutA,0.3,0.63,0.3,32,3, +scoutA,0.3,63,1.5,20,3, +scoutA,0.3,2.0,0.3,20,3, +scoutA,0.3,0.2,1.5,100,3, +ref_a2,0.3,0.63,1.5,32,3, +scoutA,0.3,6.3,1.5,20,3, +scoutA,0.3,200,0.3,20,3, +scoutA,0.3,20,1.5,20,3, +scoutA,0.3,63,0.3,20,3, +scoutB,0.3,63,0.3,20,3, +scoutB,0.3,20,1.5,20,3, +scoutB,0.3,200,0.3,20,3, +scoutB,0.3,6.3,1.5,20,3, +scoutB,0.3,0.2,1.5,100,3, +ref_b1,0.3,0.63,1.5,32,3, +scoutB,0.3,2.0,0.3,20,3, +scoutB,0.3,63,1.5,20,3, +scoutB,0.3,0.63,0.3,32,3, +scoutB,0.3,6.3,0.3,20,3, +scoutB,0.3,200,1.5,20,3, +ref_b2,0.3,0.63,1.5,32,3, +scoutB,0.3,0.63,1.5,32,3, +scoutB,0.3,20,0.3,20,3, +scoutB,0.3,0.2,0.3,100,3, +scoutB,0.3,2.0,1.5,20,3, +ref_close,0.3,0.63,1.5,32,8, diff --git a/plugins/stage-a-a1/protocols/example.toml b/plugins/stage-a-a1/protocols/example.toml index 1718b5d..6e86f4f 100644 --- a/plugins/stage-a-a1/protocols/example.toml +++ b/plugins/stage-a-a1/protocols/example.toml @@ -36,6 +36,7 @@ # named in the status line rather than stopping the survey. name = "a1-example-survey" +version = "2026-08-13" # Applied to every block that does not override them. [defaults] diff --git a/plugins/stage-a-a1/src/protocol.rs b/plugins/stage-a-a1/src/protocol.rs index 0b0346d..b1f6f63 100644 --- a/plugins/stage-a-a1/src/protocol.rs +++ b/plugins/stage-a-a1/src/protocol.rs @@ -127,6 +127,10 @@ impl ProtocolPoint { #[derive(Debug, Clone, PartialEq)] pub struct Protocol { pub name: String, + /// Optional revision declared by the protocol author. The exact source + /// file is archived separately, so this is a human-facing revision, not a + /// substitute for content identity. + pub version: Option, pub points: Vec, pub camera: Option, } @@ -201,6 +205,8 @@ struct ProtocolDoc { #[serde(default)] name: Option, #[serde(default)] + version: Option, + #[serde(default)] defaults: Defaults, #[serde(default, rename = "block")] blocks: Vec, @@ -450,6 +456,7 @@ pub fn parse(text: &str) -> Result { .name .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| "protocol".to_owned()), + version: doc.version.filter(|version| !version.trim().is_empty()), points, camera, }) @@ -484,7 +491,14 @@ pub fn parse_file(path: &str, text: &str) -> Result { .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("csv")); if is_csv { - parse_csv(text) + let mut protocol = parse_csv(text)?; + protocol.name = std::path::Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.is_empty()) + .unwrap_or("protocol") + .to_owned(); + Ok(protocol) } else { parse(text) } @@ -676,6 +690,7 @@ pub fn parse_csv(text: &str) -> Result { } Ok(Protocol { name: "protocol".to_owned(), + version: None, points, camera: camera_profile.map(CameraSelection::NamedProfile), }) @@ -687,6 +702,7 @@ mod tests { const SAMPLE: &str = r#" name = "sample" +version = "2026-08-13" [defaults] duration_s = 5 @@ -721,6 +737,7 @@ duration_s = 30 fn a_bom_does_not_break_the_toml_form_either() { let protocol = parse(&format!("\u{feff}{SAMPLE}")).expect("BOM-prefixed TOML"); assert_eq!(protocol.name, "sample"); + assert_eq!(protocol.version.as_deref(), Some("2026-08-13")); } #[test] @@ -1053,6 +1070,13 @@ slow,0.4,1,0.8,40,4, assert_eq!(order, vec![(10.0, 0.02), (10.0, 0.5), (1.0, 0.8)]); } + #[test] + fn csv_protocol_identity_comes_from_its_source_filename() { + let protocol = parse_file("a1_fc_flux_discriminator.csv", SAMPLE).expect("valid CSV"); + assert_eq!(protocol.name, "a1_fc_flux_discriminator"); + assert_eq!(protocol.version, None); + } + /// The reason for the row-per-recording form: a low frequency needs longer /// than a high one, with no block gymnastics to express it. #[test] diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index e55a22f..2a322e4 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -57,6 +57,7 @@ use augur_plugin_api::{ }; use serde::Serialize; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use stage_a_plugin_contract::{ ClientId, ConnectionStateV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, ModulationStateV1, OpticalTargetV1, PdqReceiptV1, PdqStartSpecV1, @@ -846,6 +847,9 @@ enum ProtocolPhase { /// implicitly at whatever the operator last armed. struct ProtocolRun { plan: protocol::Protocol, + source_path: String, + source_sha256: String, + source_text: String, phase: ProtocolPhase, index: usize, lease_id: LeaseId, @@ -902,6 +906,9 @@ fn a1_camera_configuration_refusal( if snapshot.digital_filter.stc_enabled || snapshot.digital_filter.trail_enabled { return Some("STC and Trail must be disabled for an event-count protocol"); } + if snapshot.digital_filter.erc_enabled != Some(false) { + return Some("ERC must be explicitly reported disabled for an event-count protocol"); + } None } @@ -2152,12 +2159,18 @@ impl StageAA1Plugin { // it came from. `measured_a` keeps its historical meaning — a number the // photodiode actually measured — so an open-loop run simply does not // carry one, rather than carrying a commanded value under that name. - meta.insert("depth_a_source".into(), self.depth_source.label().into()); + meta.insert( + "depth_a_analysis_source".into(), + self.depth_source.label().into(), + ); if let Some(a) = self.depth_a() { - meta.insert("depth_a".into(), format!("{a:.6}")); + meta.insert("depth_a_analysis".into(), format!("{a:.6}")); + } + if let Some(a) = self.commanded_a() { + meta.insert("depth_a_commanded".into(), format!("{a:.6}")); } if let Some(a) = self.photodiode_a() { - meta.insert("measured_a".into(), format!("{a:.6}")); + meta.insert("depth_a_measured".into(), format!("{a:.6}")); } if let Some(hz) = self.period_us().map(|t| 1_000_000.0 / t) { meta.insert("modulation_frequency_hz".into(), format!("{hz:.6}")); @@ -2176,25 +2189,26 @@ impl StageAA1Plugin { } if let Some(run) = self.protocol.as_ref() { meta.insert("a1_protocol_name".into(), run.plan.name.clone()); - meta.insert("a1_protocol_point".into(), (run.index + 1).to_string()); - if let Some(provenance) = run.camera_provenance.as_ref() { - meta.insert( - "camera_configuration_sha256".into(), - provenance.sha256.clone(), - ); + if let Some(version) = run.plan.version.as_ref() { + meta.insert("a1_protocol_version".into(), version.clone()); + } + meta.insert( + "a1_protocol_source_sha256".into(), + run.source_sha256.clone(), + ); + if let Some(file) = Path::new(&run.source_path).file_name() { meta.insert( - "camera_configuration_schema_version".into(), - provenance.schema_version.to_string(), + "a1_protocol_source_file".into(), + file.to_string_lossy().into_owned(), ); } - if let Some(provenance) = run.camera_profile_provenance.as_ref() { - meta.insert("camera_profile_sha256".into(), provenance.sha256.clone()); - if let Some(name) = provenance.profile_name.as_ref() { - meta.insert("camera_profile_name".into(), name.clone()); - } - if let Some(revision) = provenance.profile_revision { - meta.insert("camera_profile_revision".into(), revision.to_string()); - } + meta.insert("a1_protocol_point".into(), (run.index + 1).to_string()); + meta.insert( + "a1_protocol_points".into(), + run.plan.points.len().to_string(), + ); + if let Some(point) = run.point() { + meta.insert("a1_protocol_point_label".into(), point.block.clone()); } if let Some(point) = run.point() { if let Some(diff_on) = point.diff_on { @@ -2204,19 +2218,6 @@ impl StageAA1Plugin { meta.insert("requested_diff_off".into(), diff_off.to_string()); } } - if let Some((applied, readback, age_s)) = run.bias_confirmation { - meta.insert("confirmed_diff_on".into(), applied.diff_on.to_string()); - meta.insert("confirmed_diff_off".into(), applied.diff_off.to_string()); - meta.insert( - "confirmed_diff_on_code".into(), - readback.current.diff_on.to_string(), - ); - meta.insert( - "confirmed_diff_off_code".into(), - readback.current.diff_off.to_string(), - ); - meta.insert("bias_readback_age_s".into(), format!("{age_s:.3}")); - } } // Bench conditions, on every run and every role. Each key appears only // when the sensor actually reported that quantity — an absent reading @@ -3794,7 +3795,8 @@ impl StageAA1Plugin { let mut readings = lock.samples.clone(); if readings.is_empty() { // The owner withholds `a` for a stated reason (clipping, no - // headroom, a bad `I_tot` anchor, a sub-cycle window). Ask the + // headroom, an invalid placement-specific reference, a sub-cycle + // window). Ask the // blocker for it rather than leaving the operator with "nothing // happened" — and it answers for whichever source is selected. let reason = self @@ -3813,8 +3815,8 @@ impl StageAA1Plugin { self.finish_a0_lock( context, format!( - "a₀ lock aborted: the {} a = {measured:.3} — check the I_tot anchor and that \ - the drive is modulating", + "a₀ lock aborted: the {} a = {measured:.3} — check the photodiode placement, \ + its dark/anchor gate, and that the drive is modulating", self.depth_source.verb() ), ); @@ -3828,7 +3830,8 @@ impl StageAA1Plugin { format!( "a₀ lock aborted at {}: the observed a is not settled — {} readings spread \ {spread:.3} across {}× the ±{tolerance:.3} tolerance (median {measured:.3}). \ - Increase Sweep settle (s) or check the drive and the I_tot anchor", + Increase Sweep settle (s), or check the drive and the placement-specific \ + photodiode reference", frequency_label(hz), readings.len(), A0_LOCK_MAX_SPREAD_TOLERANCES, @@ -4587,6 +4590,7 @@ impl StageAA1Plugin { return; } }; + let source_sha256 = format!("{:x}", Sha256::digest(text.as_bytes())); // The photodiode measures `a` over one window for every frequency, so // the lowest frequency in the file decides whether the survey is // measurable at all. Refuse the plan, not its 40th point. @@ -4645,6 +4649,9 @@ impl StageAA1Plugin { ); self.protocol = Some(ProtocolRun { plan, + source_path: path, + source_sha256, + source_text: text, phase, index: 0, lease_id, @@ -5862,6 +5869,8 @@ impl StageAA1Plugin { /// Build and write the A1 config sidecar linking the RAW + PDQ files. fn write_sidecar(&self) -> Result { let now_ms = now_unix_ms(); + let dir = PathBuf::from(&self.recording.folder).join(&self.recording.id); + std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?; let modulation = self .modulation .as_ref() @@ -5878,7 +5887,7 @@ impl StageAA1Plugin { .optical .as_ref() .or_else(|| self.fresh_optical_summary()); - if optical.is_none() { + if optical.is_none() && self.depth_source == DepthSource::Photodiode { // The refusal used to stop at "no fresh summary", which reads as a // missing anchor and sends the operator to re-confirm one that was // already fine. The owner knows which estimator gate rejected the @@ -5887,13 +5896,11 @@ impl StageAA1Plugin { // unattended protocol run leaves behind for the point it lost. return Err(format!( "cannot write a quantitative A1 sidecar without a fresh photodiode optical \ - summary from a confirmed I_tot anchor: {}", + summary that passed the selected placement's optical gates: {}", self.optical_summary_blocker() .unwrap_or_else(|| "the photodiode gave no reason".into()) )); } - let roi = self.host_roi.unwrap_or_default(); - let raw_path = self .recording .cam_finalized_path @@ -5901,7 +5908,60 @@ impl StageAA1Plugin { .or_else(|| self.recording.cam_raw_path.clone()); let camera_bias_sidecar = raw_path.as_deref().and_then(sibling_toml); + let protocol = self + .protocol + .as_ref() + .map(|run| { + let extension = Path::new(&run.source_path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("txt"); + let short_hash = &run.source_sha256[..12.min(run.source_sha256.len())]; + let archive_name = format!("a1_protocol_{short_hash}.{extension}"); + let archive_path = dir.join(&archive_name); + if !archive_path.exists() { + std::fs::write(&archive_path, &run.source_text) + .map_err(|error| format!("archiving protocol source failed: {error}"))?; + } + let point = run.point(); + Ok::<_, String>(ProtocolSidecar { + name: run.plan.name.clone(), + version: run.plan.version.clone(), + source_file: Path::new(&run.source_path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| run.source_path.clone()), + source_sha256: run.source_sha256.clone(), + archived_file: archive_name, + point_index: run.index + 1, + point_total: run.plan.points.len(), + point_label: point.map(|point| point.block.clone()), + point_role: point.map(|point| { + match point.role { + protocol::PointRole::Normal => "normal", + protocol::PointRole::Pilot => "pilot", + protocol::PointRole::Background => "background", + } + .to_owned() + }), + requested_mean_u: point.map(|point| point.mean_u), + requested_frequency_hz: point.map(|point| point.frequency_hz), + requested_depth_a: point.map(|point| point.depth_a), + requested_diff_on: point.and_then(|point| point.diff_on), + requested_diff_off: point.and_then(|point| point.diff_off), + }) + }) + .transpose()?; + let direct_dark = optical + .and_then(|summary| summary.calibration.dark_reference.as_ref()) + .or_else(|| { + self.photodiode + .as_ref() + .and_then(|summary| summary.dark_reference.as_ref()) + }); + let doc = SidecarDoc { + schema: "stage-a.a1.sidecar.v2".into(), measurement_id: self.recording.id.clone(), file_stem: self.recording.stem.clone(), role: self.recording.role.label().into(), @@ -5914,12 +5974,15 @@ impl StageAA1Plugin { ), finalized_at_utc: format_iso_utc(now_ms / 1_000), duration_s: self.recording.duration_s, - depth_a_source: self.depth_source.label().into(), - // Measured `a` comes from the same latched window as the rest of the - // optical section, so the two can never disagree. - depth_a: match self.depth_source { - DepthSource::Photodiode => optical.map(|o| o.measured_log_contrast), - DepthSource::Commanded => self.commanded_a(), + protocol, + depth: DepthSidecar { + analysis_source: self.depth_source.label().into(), + analysis_a: match self.depth_source { + DepthSource::Photodiode => optical.map(|o| o.measured_log_contrast), + DepthSource::Commanded => self.commanded_a(), + }, + commanded_a: self.commanded_a(), + measured_a: optical.map(|o| o.measured_log_contrast), }, sweep: { let point = self @@ -5991,7 +6054,6 @@ impl StageAA1Plugin { resolved_mean_u: mod_optical .map(|drive| f64::from(drive.resolved_mean_u_milli) / 1_000.0), internal_u: mod_optical.map(|drive| f64::from(drive.internal_u_milli) / 1_000.0), - requested_a: mod_optical.map(|drive| f64::from(drive.depth_a_milli) / 1_000.0), v_null_dac: mod_optical.map(|drive| drive.v_null_dac), v_peak_dac: mod_optical.map(|drive| drive.v_peak_dac), center_dac: a1_config.map(|c| c.center_dac), @@ -6000,80 +6062,41 @@ impl StageAA1Plugin { .and_then(|t| t.waveform.as_ref()) .map(waveform_label), }, - optical: OpticalSidecar { + photodiode: PhotodiodeSidecar { + placement: optical + .map(|o| o.placement) + .or_else(|| self.photodiode.as_ref().map(|summary| summary.placement)), + splitter_fraction: optical.and_then(|o| o.splitter_fraction).or_else(|| { + self.photodiode + .as_ref() + .and_then(|summary| summary.splitter_fraction) + }), measured_a: optical.map(|o| o.measured_log_contrast), - geometric_mean_excitation_volts: optical + geometric_mean_detector_volts: optical .map(|o| (o.excitation_min_volts * o.excitation_max_volts).sqrt()), - excitation_min_volts: optical.map(|o| o.excitation_min_volts), - excitation_max_volts: optical.map(|o| o.excitation_max_volts), - excitation_headroom_volts: optical.map(|o| o.excitation_headroom_volts), + detector_min_volts: optical.map(|o| o.excitation_min_volts), + detector_max_volts: optical.map(|o| o.excitation_max_volts), + detector_headroom_volts: optical.map(|o| o.excitation_headroom_volts), low_clip_fraction: optical.map(|o| o.low_clip_fraction), high_clip_fraction: optical.map(|o| o.high_clip_fraction), measured_frequency_hz: optical.and_then(|o| o.measured_frequency_hz), adc_calibration_id: optical.map(|o| o.calibration.adc_calibration_id.clone()), - dark_id: optical.map(|o| o.calibration.dark_id.clone()), - total_power_anchor_id: optical.map(|o| o.calibration.anchor_id.clone()), - dark_volts: optical.map(|o| o.calibration.dark_volts), - total_power_volts: optical.map(|o| o.calibration.total_power_volts), + dark_id: optical + .map(|o| o.calibration.dark_id.clone()) + .or_else(|| direct_dark.map(|dark| dark.dark_id.clone())), + dark_source: direct_dark.map(|dark| dark.source), + dark_volts: optical + .map(|o| o.calibration.dark_volts) + .or_else(|| direct_dark.map(|dark| dark.dark_volts)), + dark_captured_at_unix_ms: direct_dark.map(|dark| dark.captured_at_unix_ms), + dark_age_s: direct_dark + .map(|dark| now_ms.saturating_sub(dark.captured_at_unix_ms) as f64 / 1_000.0), }, - camera: CameraSidecar { - roi_x: roi.x, - roi_y: roi.y, - roi_width: roi.width, - roi_height: roi.height, - masked_pixels: self.masked_pixels.len(), - n_valid: self.valid_pixel_count(), - }, - camera_control: self.protocol.as_ref().and_then(|run| { - let point = run.point(); - let point_requested = - point.is_some_and(|point| point.diff_on.is_some() || point.diff_off.is_some()); - if run.camera_snapshot.is_none() && !point_requested { - return None; - } - let (confirmed_offsets, confirmed_readback, readback_age_s) = run - .bias_confirmation - .map(|(offsets, readback, age_s)| (Some(offsets), Some(readback), Some(age_s))) - .or_else(|| { - run.camera_confirmation.map(|(readback, age_s)| { - let offsets = - run.camera_snapshot.as_ref().map(|snapshot| snapshot.biases); - (offsets, Some(readback), Some(age_s)) - }) - }) - .unwrap_or((None, None, None)); - Some(CameraControlSidecar { - snapshot: run.camera_snapshot.clone(), - provenance: run.camera_provenance.clone(), - profile_provenance: run.camera_profile_provenance.clone(), - requested_diff_on: point.and_then(|point| point.diff_on), - requested_diff_off: point.and_then(|point| point.diff_off), - confirmed_offsets, - confirmed_readback, - readback_age_s, - status: "confirmed".into(), - }) - }), - sensor: self.recorded_sensor().map(|sensor| { - let readback = sensor.bias_codes; - let codes = readback.map(|readback| readback.current); - let factory = readback.map(|readback| readback.factory_default); - SensorSidecar { - temperature_c: sensor.temperature_c, - pixel_dead_time_us: sensor.pixel_dead_time_us, - illumination_lux: sensor.illumination_lux, - reading_age_s: sensor.age_s, - bias_diff_on: codes.map(|c| c.diff_on), - bias_diff_off: codes.map(|c| c.diff_off), - bias_fo: codes.map(|c| c.fo), - bias_hpf: codes.map(|c| c.hpf), - bias_refr: codes.map(|c| c.refr), - factory_diff_on: factory.map(|c| c.diff_on), - factory_diff_off: factory.map(|c| c.diff_off), - factory_fo: factory.map(|c| c.fo), - factory_hpf: factory.map(|c| c.hpf), - factory_refr: factory.map(|c| c.refr), - } + sensor: self.recorded_sensor().map(|sensor| SensorSidecar { + temperature_c: sensor.temperature_c, + pixel_dead_time_us: sensor.pixel_dead_time_us, + illumination_lux: sensor.illumination_lux, + reading_age_s: sensor.age_s, }), trigger: TriggerSidecar { marker_anchored: self.is_marker_anchored(), @@ -6090,8 +6113,6 @@ impl StageAA1Plugin { }; let toml = toml::to_string_pretty(&doc).map_err(|err| err.to_string())?; - let dir = PathBuf::from(&self.recording.folder).join(&self.recording.id); - std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?; let path = dir.join(format!("{}_config.toml", self.recording.stem)); std::fs::write(&path, toml).map_err(|err| err.to_string())?; Ok(path.display().to_string()) @@ -6102,23 +6123,16 @@ impl StageAA1Plugin { #[derive(Serialize)] struct SidecarDoc { + schema: String, measurement_id: String, file_stem: String, role: String, recorded_at_utc: String, finalized_at_utc: String, duration_s: u64, - /// The modulation depth this run was driven and judged by, and which source - /// produced it (`photodiode_measured` / `modulation_commanded`). - /// - /// Written on every run, so offline analysis never has to infer the depth's - /// provenance from which of `optical.measured_a` and `modulation.requested_a` - /// happens to be present. A commanded depth is an open-loop number carrying - /// the Pockels calibration's error; a fit that mixes the two sources without - /// looking here would silently mix two error budgets. - depth_a_source: String, #[serde(skip_serializing_if = "Option::is_none")] - depth_a: Option, + protocol: Option, + depth: DepthSidecar, sweep: SweepSidecar, /// Present on **event-count** points: the `a₀` lock this point replayed. #[serde(skip_serializing_if = "Option::is_none")] @@ -6131,11 +6145,7 @@ struct SidecarDoc { #[serde(skip_serializing_if = "Option::is_none")] background: Option, modulation: ModulationSidecar, - optical: OpticalSidecar, - camera: CameraSidecar, - /// Host-routed camera settings and fresh readback for this protocol point. - #[serde(skip_serializing_if = "Option::is_none")] - camera_control: Option, + photodiode: PhotodiodeSidecar, /// Absent when the host had no camera able to measure these (replay, /// imports, a sensor without a monitoring block). #[serde(skip_serializing_if = "Option::is_none")] @@ -6144,6 +6154,46 @@ struct SidecarDoc { files: FilesSidecar, } +#[derive(Serialize)] +struct ProtocolSidecar { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + source_file: String, + source_sha256: String, + archived_file: String, + point_index: usize, + point_total: usize, + #[serde(skip_serializing_if = "Option::is_none")] + point_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + point_role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_mean_u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_frequency_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_depth_a: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_diff_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_diff_off: Option, +} + +#[derive(Serialize)] +struct DepthSidecar { + /// Value selected for online gates and offline analysis. + analysis_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + analysis_a: Option, + /// Optical drive command, never described as measured. + #[serde(skip_serializing_if = "Option::is_none")] + commanded_a: Option, + /// Independent photodiode estimate, never filled from a drive command. + #[serde(skip_serializing_if = "Option::is_none")] + measured_a: Option, +} + #[derive(Serialize)] struct SweepSidecar { min_a: f64, @@ -6245,8 +6295,6 @@ struct ModulationSidecar { #[serde(skip_serializing_if = "Option::is_none")] internal_u: Option, #[serde(skip_serializing_if = "Option::is_none")] - requested_a: Option, - #[serde(skip_serializing_if = "Option::is_none")] v_null_dac: Option, #[serde(skip_serializing_if = "Option::is_none")] v_peak_dac: Option, @@ -6259,17 +6307,21 @@ struct ModulationSidecar { } #[derive(Serialize)] -struct OpticalSidecar { +struct PhotodiodeSidecar { + #[serde(skip_serializing_if = "Option::is_none")] + placement: Option, + #[serde(skip_serializing_if = "Option::is_none")] + splitter_fraction: Option, #[serde(skip_serializing_if = "Option::is_none")] measured_a: Option, #[serde(skip_serializing_if = "Option::is_none")] - geometric_mean_excitation_volts: Option, + geometric_mean_detector_volts: Option, #[serde(skip_serializing_if = "Option::is_none")] - excitation_min_volts: Option, + detector_min_volts: Option, #[serde(skip_serializing_if = "Option::is_none")] - excitation_max_volts: Option, + detector_max_volts: Option, #[serde(skip_serializing_if = "Option::is_none")] - excitation_headroom_volts: Option, + detector_headroom_volts: Option, #[serde(skip_serializing_if = "Option::is_none")] low_clip_fraction: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -6281,43 +6333,13 @@ struct OpticalSidecar { #[serde(skip_serializing_if = "Option::is_none")] dark_id: Option, #[serde(skip_serializing_if = "Option::is_none")] - total_power_anchor_id: Option, + dark_source: Option, #[serde(skip_serializing_if = "Option::is_none")] dark_volts: Option, #[serde(skip_serializing_if = "Option::is_none")] - total_power_volts: Option, -} - -#[derive(Serialize)] -struct CameraSidecar { - roi_x: u16, - roi_y: u16, - roi_width: u16, - roi_height: u16, - masked_pixels: usize, + dark_captured_at_unix_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] - n_valid: Option, -} - -#[derive(Serialize)] -struct CameraControlSidecar { - #[serde(skip_serializing_if = "Option::is_none")] - snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - provenance: Option, - #[serde(skip_serializing_if = "Option::is_none")] - profile_provenance: Option, - #[serde(skip_serializing_if = "Option::is_none")] - requested_diff_on: Option, - #[serde(skip_serializing_if = "Option::is_none")] - requested_diff_off: Option, - #[serde(skip_serializing_if = "Option::is_none")] - confirmed_offsets: Option, - #[serde(skip_serializing_if = "Option::is_none")] - confirmed_readback: Option, - #[serde(skip_serializing_if = "Option::is_none")] - readback_age_s: Option, - status: String, + dark_age_s: Option, } /// Bench conditions the sensor measured for itself at the start of the run. @@ -6342,28 +6364,6 @@ struct SensorSidecar { /// Seconds between the host's last read of these values and the moment the /// recording started — the host polls at a few hertz, so this is never 0. reading_age_s: f64, - /// Absolute programmed bias codes, and the per-unit factory trim the - /// host's relative offsets are expressed against. - #[serde(skip_serializing_if = "Option::is_none")] - bias_diff_on: Option, - #[serde(skip_serializing_if = "Option::is_none")] - bias_diff_off: Option, - #[serde(skip_serializing_if = "Option::is_none")] - bias_fo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - bias_hpf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - bias_refr: Option, - #[serde(skip_serializing_if = "Option::is_none")] - factory_diff_on: Option, - #[serde(skip_serializing_if = "Option::is_none")] - factory_diff_off: Option, - #[serde(skip_serializing_if = "Option::is_none")] - factory_fo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - factory_hpf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - factory_refr: Option, } #[derive(Serialize)] @@ -8218,10 +8218,13 @@ mod tests { calibration: stage_a_plugin_contract::PhotodiodeCalibrationV1 { adc_calibration_id: "adc".into(), dark_id: "dark".into(), - anchor_id: "anchor".into(), + anchor_id: Some("anchor".into()), dark_volts: 0.0, - total_power_volts: 1.0, + dark_reference: None, + total_power_volts: Some(1.0), }, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, measured_log_contrast: measured_a, log_contrast_stddev: None, excitation_min_volts: 0.1, @@ -8238,6 +8241,9 @@ mod tests { covered_cycles: Some(8.0), }), optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, synchronization: stage_a_plugin_contract::SynchronizationV1::Unsynced { reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, detail: None, @@ -8413,6 +8419,7 @@ mod tests { frequency_millihz: (hz * 1_000.0).round() as u64, }), a1_configuration: None, + a2_configuration: None, acquisition_running: true, board_dac_code: None, firmware_configuration_revision: None, @@ -8864,7 +8871,10 @@ mod tests { assert!(text.contains("temperature_c = 41.25"), "{text}"); assert!(text.contains("pixel_dead_time_us = 102.5"), "{text}"); assert!(text.contains("illumination_lux = 742.0"), "{text}"); - assert!(text.contains("bias_refr = 20"), "{text}"); + assert!( + !text.contains("bias_refr") && !text.contains("factory_diff_on"), + "camera configuration must stay in the host sidecar: {text}" + ); let _ = std::fs::remove_file(&doc); } @@ -8903,23 +8913,33 @@ mod tests { let meta = plugin.recording_metadata(); assert_eq!( - meta.get("depth_a_source").map(String::as_str), + meta.get("depth_a_analysis_source").map(String::as_str), Some("modulation_commanded") ); - assert_eq!(meta.get("depth_a").map(String::as_str), Some("0.750000")); + assert_eq!( + meta.get("depth_a_analysis").map(String::as_str), + Some("0.750000") + ); + assert_eq!( + meta.get("depth_a_commanded").map(String::as_str), + Some("0.750000") + ); assert!( - !meta.contains_key("measured_a"), - "`measured_a` names a measurement, and there was none" + !meta.contains_key("depth_a_measured"), + "`depth_a_measured` names a measurement, and there was none" ); plugin.depth_source = DepthSource::Photodiode; plugin.photodiode = Some(photodiode_measuring(1, 0.42)); let meta = plugin.recording_metadata(); assert_eq!( - meta.get("depth_a_source").map(String::as_str), + meta.get("depth_a_analysis_source").map(String::as_str), Some("photodiode_measured") ); - assert_eq!(meta.get("measured_a").map(String::as_str), Some("0.420000")); + assert_eq!( + meta.get("depth_a_measured").map(String::as_str), + Some("0.420000") + ); } fn pd_reply(request_id: u64, receipt: Option) -> PluginServiceReply { @@ -8976,6 +8996,9 @@ mod tests { last_finalized_recording: None, optical_summary: None, optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, synchronization: SynchronizationV1::Unsynced { reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, detail: None, @@ -9026,10 +9049,13 @@ mod tests { calibration: PhotodiodeCalibrationV1 { adc_calibration_id: "adc-test".into(), dark_id: "dark-test".into(), - anchor_id: "itot-test".into(), + anchor_id: Some("itot-test".into()), dark_volts: 0.05, - total_power_volts: 3.0, + dark_reference: None, + total_power_volts: Some(3.0), }, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, measured_log_contrast: 1.0, log_contrast_stddev: None, excitation_min_volts: 0.8, @@ -9044,6 +9070,9 @@ mod tests { covered_cycles: Some(8.0), }), optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, synchronization: SynchronizationV1::Unsynced { reason: UnsyncedReasonV1::NoLease, detail: None, @@ -10155,7 +10184,7 @@ mod tests { "the sidecar must carry the measured a from the recording: {written}" ); assert!( - written.contains(&format!("depth_a = {latched}")), + written.contains(&format!("analysis_a = {latched}")), "the recorded depth must come from the same window: {written}" ); let _ = std::fs::remove_dir_all(&folder); @@ -10747,6 +10776,7 @@ mod tests { const TWO_POINT_PROTOCOL: &str = r#" name = "two-point" +version = "test-v2" [defaults] duration_s = 3 @@ -10759,6 +10789,53 @@ frequency_hz = 25.0 depth_a = 0.7 "#; + #[test] + fn protocol_identity_and_exact_source_are_archived_with_each_point() { + let folder = temp_folder("protocol-provenance"); + let (mut plugin, source) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.photodiode = Some(fresh_photodiode_summary()); + if let Some(optical) = plugin + .photodiode + .as_mut() + .and_then(|summary| summary.optical_summary.as_mut()) + { + optical.window_seconds = Some(1.0); + optical.covered_cycles = Some(25.0); + } + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + plugin.recording.id = "A1-proto".into(); + plugin.recording.stem = "A1-proto_20260813-120000".into(); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.duration_s = 3; + plugin.recording.start_unix_ms = now_unix_ms(); + let sidecar = plugin.write_sidecar().expect("v2 sidecar"); + let text = std::fs::read_to_string(&sidecar).expect("sidecar text"); + + assert!(text.contains("[protocol]")); + assert!(text.contains("name = \"two-point\"")); + assert!(text.contains("version = \"test-v2\"")); + assert!(text.contains("source_file = \"protocol.toml\"")); + assert!(text.contains("source_sha256 = \"")); + assert!(text.contains("point_label = \"pair\"")); + let archived = std::fs::read_dir(folder.join("A1-proto")) + .expect("measurement folder") + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("a1_protocol_")) + }) + .expect("archived protocol"); + assert_eq!( + std::fs::read_to_string(archived).expect("archived source"), + std::fs::read_to_string(source).expect("original source") + ); + + let _ = std::fs::remove_dir_all(&folder); + } + const BIAS_PROTOCOL: &str = r#" name = "bias-point" @@ -10880,6 +10957,7 @@ depth_a = 0.7 stc_enabled: false, stc_threshold_us: 0, trail_enabled: false, + erc_enabled: Some(false), }, external_trigger: augur_plugin_api::CameraExternalTriggerV1::default(), global: augur_plugin_api::CameraGlobalSettingsV1 { @@ -11012,9 +11090,10 @@ depth_a = 0.7 metadata.get("requested_diff_on").map(String::as_str), Some("12") ); - assert_eq!( - metadata.get("confirmed_diff_on_code").map(String::as_str), - Some("112") + assert!( + !metadata.contains_key("confirmed_diff_on_code") + && !metadata.contains_key("camera_configuration_sha256"), + "host camera configuration must not be copied into A1 metadata: {metadata:?}" ); plugin.recording = Recording::idle(); @@ -11107,6 +11186,34 @@ depth_a = 0.7 let _ = std::fs::remove_dir_all(&folder); } + #[test] + fn an_older_snapshot_without_erc_state_is_refused() { + let mut value = serde_json::to_value(camera_snapshot()).expect("serialize snapshot"); + value["digital_filter"] + .as_object_mut() + .expect("digital filter object") + .remove("erc_enabled"); + let snapshot: CameraConfigurationSnapshotV1 = + serde_json::from_value(value).expect("older snapshot remains decodable"); + + assert_eq!(snapshot.digital_filter.erc_enabled, None); + assert_eq!( + a1_camera_configuration_refusal(&snapshot), + Some("ERC must be explicitly reported disabled for an event-count protocol") + ); + } + + #[test] + fn a_confirmed_configuration_with_erc_enabled_is_refused() { + let mut snapshot = camera_snapshot(); + snapshot.digital_filter.erc_enabled = Some(true); + + assert_eq!( + a1_camera_configuration_refusal(&snapshot), + Some("ERC must be explicitly reported disabled for an event-count protocol") + ); + } + #[test] fn a_rejected_bias_point_aborts_without_recording_and_still_restores() { let folder = temp_folder("protocol-bias-abort-restore"); @@ -11858,14 +11965,59 @@ bias_refr_code,status,error\n\ assert!(text.contains("measurement_id = \"A1-test\"")); // Provenance of `a` is unconditional: offline analysis must never have // to guess whether a run's depth was measured or merely commanded. - assert!(text.contains("depth_a_source = \"photodiode_measured\"")); + assert!(text.contains("schema = \"stage-a.a1.sidecar.v2\"")); + assert!(text.contains("analysis_source = \"photodiode_measured\"")); + assert!(text.contains("[depth]")); assert!(text.contains("[modulation]")); - assert!(text.contains("[camera]")); + assert!(text.contains("[photodiode]")); + assert!(!text.contains("[camera_control]")); + assert!(!text.contains("total_power_volts")); assert!(text.contains("[files]")); assert!(text.contains("camera_config_sidecar = \"/data/A1-test/A1-test.toml\"")); let _ = std::fs::remove_file(&doc); } + #[test] + fn commanded_depth_sidecar_does_not_require_a_measured_optical_summary() { + let folder = temp_folder("commanded-sidecar"); + let mut photodiode = ready_photodiode(); + photodiode.placement = stage_a_plugin_contract::PhotodiodePlacementV1::EmissionPath; + photodiode.splitter_fraction = Some(0.5); + photodiode.dark_reference = Some(stage_a_plugin_contract::PhotodiodeDarkReferenceV1 { + dark_id: "lamp-off@sample-42@1774223990000".into(), + source: stage_a_plugin_contract::PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: 0.012, + captured_at_unix_ms: 1_774_223_990_000, + age_s: 10.0, + }); + let mut plugin = StageAA1Plugin { + depth_source: DepthSource::Commanded, + modulation: Some(commanded_modulation(1, 0.75)), + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + plugin.recording.id = "A1-commanded".into(); + plugin.recording.stem = "A1-commanded_20260813-120000".into(); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.duration_s = 5; + + let path = plugin.write_sidecar().expect("commanded sidecar"); + let text = std::fs::read_to_string(path).expect("sidecar text"); + assert!(text.contains("analysis_source = \"modulation_commanded\"")); + assert!(text.contains("commanded_a = 0.75")); + assert!(!text.contains("measured_a")); + assert!(text.contains("placement = \"emission_path\"")); + assert!(text.contains("splitter_fraction = 0.5")); + assert!(text.contains("dark_id = \"lamp-off@sample-42@1774223990000\"")); + assert!(text.contains("dark_source = \"measured_lamp_off\"")); + assert!(text.contains("dark_volts = 0.012")); + assert!(text.contains("dark_captured_at_unix_ms = 1774223990000")); + assert!(text.contains("dark_age_s = ")); + assert!(!text.contains("total_power")); + + let _ = std::fs::remove_dir_all(folder); + } + #[test] fn pilot_windows_round_trip_through_the_folder() { let dir = std::env::temp_dir().join(format!("a1-pilot-{}", now_unix_ms())); From efbe652ca19781b1715be8e0fe7f5ad583e57974 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:30 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(stage-a-a2):=20=E2=9C=A8=20add=20the?= =?UTF-8?q?=20fluorescence=20step-latency=20protocol=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 measures how long the fluorescence chain takes to answer a step. It owns no hardware: the Teensy belongs to the modulation owner and the detector to the photodiode owner, so A2 drives both over the service plane and never opens a port itself. The protocol file is the aggregate root. Optical configuration, the qualified hardware gates, controller settings and the ordered recording rows are validated together before anything happens, because a bring-up file that is half filled in is useful to read and must not be runnable — an explicit TBD gate refuses preflight rather than silently defaulting. Dark rows and stepped rows are separate acquisition types: a dark row forces the modulation safe and carries no trigger-count gate, so a missing trigger cannot be reported as a dark measurement. The plugin records acquisition provenance and live integrity evidence only. Censored first-event fits stay offline, where the model can be chosen with the data in hand. Refs ADR 038. --- Cargo.toml | 1 + docs/adr/038-stage-a-a2-protocol-runner.md | 31 + docs/features/stage-a-a2.md | 50 + plugins/stage-a-a2/Cargo.toml | 21 + plugins/stage-a-a2/README.md | 28 + plugins/stage-a-a2/plugin.toml | 9 + .../a2_fluorescence_chain_followup.toml | 217 ++ plugins/stage-a-a2/src/lib.rs | 10 + plugins/stage-a-a2/src/protocol.rs | 340 +++ plugins/stage-a-a2/src/runtime.rs | 2047 +++++++++++++++++ 10 files changed, 2754 insertions(+) create mode 100644 docs/adr/038-stage-a-a2-protocol-runner.md create mode 100644 docs/features/stage-a-a2.md create mode 100644 plugins/stage-a-a2/Cargo.toml create mode 100644 plugins/stage-a-a2/README.md create mode 100644 plugins/stage-a-a2/plugin.toml create mode 100644 plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml create mode 100644 plugins/stage-a-a2/src/lib.rs create mode 100644 plugins/stage-a-a2/src/protocol.rs create mode 100644 plugins/stage-a-a2/src/runtime.rs diff --git a/Cargo.toml b/Cargo.toml index 3d4a38f..d37e297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "stage-a-io", "stage-a-plugin-contract", "plugins/stage-a-a1", + "plugins/stage-a-a2", "plugins/stage-a-modulation", "plugins/stage-a-photodiode", "plugins/localization", diff --git a/docs/adr/038-stage-a-a2-protocol-runner.md b/docs/adr/038-stage-a-a2-protocol-runner.md new file mode 100644 index 0000000..e700aa9 --- /dev/null +++ b/docs/adr/038-stage-a-a2-protocol-runner.md @@ -0,0 +1,31 @@ +# ADR 038 — A2 is a fail-closed protocol runner over existing owners + +- **Status:** Accepted +- **Date:** 2026-08-13 + +## Decision + +`stage-a-a2` uses the host service plane. It never opens a Teensy port. A TOML +protocol is the aggregate root: optical configuration, qualified hardware gates, +controller settings and ordered recording rows must be valid together before +any effect occurs. + +A complete named camera profile is part of that root. The host applies and +confirms it before either hardware lease and restores the pre-run configuration +on every terminal path. A dark row and a stepped row are different acquisition +types; dark rows force modulation safe/off and have no trigger-count gate. + +The modulation contract adds `PrepareA2`, which executes `STOP`, `CONFIG mode=A2`, +`CMP` and `MOD wave=LOG_SQUARE` as one acknowledged semantic operation. The owner +requires the firmware reply to confirm comparator trigger, armed comparator and +log-square drive. Camera RAW and photodiode PDQ are then started/stopped by their +owners and linked by one run ID. Camera configuration remains host-owned. + +The plugin stores acquisition provenance and live integrity evidence only. +Scientific first-event fits remain offline. + +## Consequences + +An incomplete bring-up file is useful but not runnable: explicit TBD gates cause +preflight refusal. The current fluorescence template records emission-path 50:50 +geometry. It must not fall back to rejected-port `I_tot` semantics. diff --git a/docs/features/stage-a-a2.md b/docs/features/stage-a-a2.md new file mode 100644 index 0000000..51b45b4 --- /dev/null +++ b/docs/features/stage-a-a2.md @@ -0,0 +1,50 @@ +# Stage-A A2 latency automation + +A2 applies repeated calibrated optical log-square steps at several fluorescence +pedestals and records the first camera event after each measured optical edge. +The workflow plugin owns no serial port. It leases and orchestrates the permanent +modulation and photodiode owners and the host camera recorder. + +## Acquisition contract + +- The full TOML is parsed and all hardware/optical gates are checked before a + lease is acquired. +- A named, complete camera profile is applied and confirmed before either + hardware lease. EXT_TRIGGER and sensor telemetry must be on; STC, Trail and + ERC must be explicitly off. The host restores the pre-run state on success, + operator stop and failure. +- `PrepareA2` is accepted only when firmware confirms comparator trigger source, + an armed comparator and `LOG_SQUARE`. +- Dark points record camera RAW and photodiode PDQ for their declared duration + with modulation forced safe/off. Stepped points additionally require the + commanded number of both EXT_TRIGGER polarities (tolerance: one edge). +- A sidecar records protocol identity/SHA-256, row, commanded pedestal/depth, + comparator configuration, optical placement, final file receipts, dynamic + sensor values and trigger/load evidence. It links the host-owned camera and + sensor-monitoring companions instead of duplicating their bias/configuration + data. The exact protocol source is archived once by SHA-256. +- Implausible stepped trigger counts, a partial RAW/PDQ, an exceeded + pre-qualified recorder safety limit, + missing sensor dead-time, stale owner reply or expired lease fails closed. +- The plugin contains no scientific fit. Censoring-aware first-event latency and + jitter are computed offline, ON and OFF separately. + +## Current bench topology + +The immediate protocol is scoped to `fluorescence_chain`: ATTO647 sample, +fluorescence filter, 50:50 splitter, camera and the sole photodiode in the +emission path. It does not use rejected-port complement geometry or `I_tot`. + +## Hardware status + +Firmware mode A2, `CMP`, `LOG_SQUARE`, `min_half_us`, comparator source ID 2 and +trigger-source status exist in `stage-a-controller`. The sources build, but the +comparator has not been bench-qualified. H4 loopback, H5 polarity/offset and the +emission optical-edge qualification remain mandatory protocol gates. + +The optional PDQ cross-check is not the A2 time base. Production firmware now +mirrors comparator marker frames (`source=2`) through the non-blocking +photodiode stream path, so a PDQ can carry the independent comparator-edge +record. Camera-clock EXT_TRIGGER remains the latency clock of record. Marker +drops are explicit firmware integrity evidence; H4 loopback and H5 +polarity/offset calibration remain mandatory and are cited in the protocol. diff --git a/plugins/stage-a-a2/Cargo.toml b/plugins/stage-a-a2/Cargo.toml new file mode 100644 index 0000000..36d85c4 --- /dev/null +++ b/plugins/stage-a-a2/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "augur-plugin-stage-a-a2" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A A2 optical step-latency protocol runner" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2 = "0.10" +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[lints.rust] +unsafe_code = "forbid" diff --git a/plugins/stage-a-a2/README.md b/plugins/stage-a-a2/README.md new file mode 100644 index 0000000..fbab665 --- /dev/null +++ b/plugins/stage-a-a2/README.md @@ -0,0 +1,28 @@ +# Stage-A A2 latency runner + +Runs a fully validated TOML protocol through the existing modulation owner, +photodiode owner and host camera recorder. One row creates one camera RAW, one +photodiode PDQ and one A2 JSON sidecar. The sidecar links the protocol and its +SHA-256, the optical/gate declarations, controller point and finalized receipts; +it does not copy the host-owned camera configuration sidecar. + +The plugin does not fit latency. It records both EXT_TRIGGER polarities and the +event-load peak needed by the offline first-event analysis. Dark rows are +explicit fixed-duration acquisitions with the modulation forced safe/off. + +The protocol must name a complete host camera profile. It is applied and +confirmed before owner leases and restored on every terminal path. The A2 +sidecar links the host camera and sensor-monitoring companions; it does not copy +their camera settings or bias readbacks. The exact protocol text is archived by +content hash in the measurement folder. + +The current fluorescence-chain template is +`protocols/a2_fluorescence_chain_followup.toml`. It deliberately contains TBD +bring-up gates and therefore refuses to run until H4, H5, the optical-edge/local- +flux calibrations, comparator threshold per row, lobe endpoints and timing floor +are measured and frozen. + +Production firmware mirrors comparator marker frames (`source=2`) onto the +non-blocking photodiode stream, so PDQ contains the independent diagnostic edge +record. Camera EXT_TRIGGER remains the latency clock of record. This does not +replace or weaken the mandatory H4 loopback and H5 polarity/offset gates. diff --git a/plugins/stage-a-a2/plugin.toml b/plugins/stage-a-a2/plugin.toml new file mode 100644 index 0000000..6d8ad62 --- /dev/null +++ b/plugins/stage-a-a2/plugin.toml @@ -0,0 +1,9 @@ +id = "stage-a.a2" +name = "Stage-A A2 Latency" +version = "0.1.0" +description = "Runs validated optical step-latency protocols with synchronized camera RAW and photodiode PDQ acquisition." +domain = "stage-a" +library = "augur_plugin_stage_a_a2" +phase = "raw_events" +min_augur_version = "2.0.2" +host_commands = ["start_recording", "stop_recording"] diff --git a/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml b/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml new file mode 100644 index 0000000..05382cc --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml @@ -0,0 +1,217 @@ +# Immediate A2 follow-up for the current ATTO647 fluorescence chain. +# +# This file is intentionally fail-closed. Replace every TBD/zero gate with the +# value or calibration ID measured during comparator/optical bring-up. The A2 +# plugin rejects the whole file before acquiring a lease while any remains. +name = "a2-atto647-fluorescence-chain-followup" + +[camera] +profile = "TBD" # Complete host profile with EXT_TRIGGER and sensor telemetry. + +[optical] +transfer_scope = "fluorescence_chain" +photodiode_placement = "emission_path" +splitter_fraction_to_pd = 0.5 +optical_config_id = "TBD" + +[gates] +firmware_a2_confirmed = false +comparator_self_test_passed = false +camera_external_trigger_confirmed = false +h4_loopback_id = "TBD" +h5_polarity_calibration_id = "TBD" +optical_edge_calibration_id = "TBD" +local_flux_calibration_id = "TBD" +recorder_safety_limit_events_per_us = 0 # Pre-qualified before this protocol. + +[controller] +v_null_dac = 0 # TBD: measured lower lobe endpoint +v_peak_dac = 0 # TBD: measured absolute lobe maximum +comparator_hysteresis = 1 +comparator_invert = false # TBD: value frozen by H5 +min_half_us = 0 # TBD: max(5*tau_refr, A1 settling guard) +sample_rate_hz = 500000 +block_samples = 256 + +# Dark/sham and cadence commissioning. A pause means the operator must confirm +# the stated physical condition before Continue. No failed row is overwritten. +[[point]] +label="floor_pre" +role="floor_pre_shutter_closed" +acquisition_mode="dark" +duration_s=30.0 +settle_s=2 +pause_before=true + +[[point]] +label="blocked_drive_sham" +role="blocked_drive_sham" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=100 +settle_s=2 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="polarity_cadence_2s" +role="commissioning" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=2.0 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="cadence_1s" +role="commissioning" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="cadence_0p5s" +role="commissioning_conditional" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=0.5 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="load_small" +role="h21_load_reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="load_larger" +role="h21_load_test" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +# Opening references and the multi-depth identification block at mean_u=0.30. +[[point]] +label="ref_open_1" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="ref_open_2" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="ref_open_3" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="depth_low" +role="identification" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.28 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="depth_high" +role="identification" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.80 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 + +# Flux/pedestal bridge matching A1. The order is non-monotonic to expose drift. +[[point]] +label="pedestal_u015" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.15 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="pedestal_u045" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.45 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="pedestal_u030" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="ref_close" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="floor_post" +role="floor_post_shutter_closed" +acquisition_mode="dark" +duration_s=30.0 +settle_s=2 +pause_before=true diff --git a/plugins/stage-a-a2/src/lib.rs b/plugins/stage-a-a2/src/lib.rs new file mode 100644 index 0000000..fb73c36 --- /dev/null +++ b/plugins/stage-a-a2/src/lib.rs @@ -0,0 +1,10 @@ +//! Stage-A A2: repeated optical-step latency acquisition. +//! +//! This plugin owns no hardware. It runs validated protocol points through the +//! persistent modulation and photodiode owners and the host camera recorder. +//! Scientific latency fits remain offline. + +pub mod protocol; +mod runtime; + +pub use runtime::StageAA2Plugin; diff --git a/plugins/stage-a-a2/src/protocol.rs b/plugins/stage-a-a2/src/protocol.rs new file mode 100644 index 0000000..890b0cc --- /dev/null +++ b/plugins/stage-a-a2/src/protocol.rs @@ -0,0 +1,340 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +pub const MAX_POINTS: usize = 4_096; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Protocol { + pub name: String, + pub camera: CameraSetup, + pub optical: OpticalSetup, + pub gates: Gates, + pub controller: ControllerSetup, + #[serde(rename = "point")] + pub points: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CameraSetup { + pub profile: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OpticalSetup { + pub transfer_scope: String, + pub photodiode_placement: String, + pub splitter_fraction_to_pd: f64, + pub optical_config_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Gates { + pub firmware_a2_confirmed: bool, + pub comparator_self_test_passed: bool, + pub camera_external_trigger_confirmed: bool, + pub h4_loopback_id: String, + pub h5_polarity_calibration_id: String, + pub optical_edge_calibration_id: String, + pub local_flux_calibration_id: String, + pub recorder_safety_limit_events_per_us: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ControllerSetup { + pub v_null_dac: u16, + pub v_peak_dac: u16, + pub comparator_hysteresis: u8, + pub comparator_invert: bool, + pub min_half_us: u32, + #[serde(default = "default_sample_rate")] + pub sample_rate_hz: u32, + #[serde(default = "default_block_samples")] + pub block_samples: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Point { + pub label: String, + pub role: String, + pub settle_s: f64, + #[serde(default)] + pub pause_before: bool, + #[serde(flatten)] + pub acquisition: Acquisition, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "acquisition_mode", rename_all = "snake_case")] +pub enum Acquisition { + Dark { + duration_s: f64, + }, + Stepped { + mean_u: f64, + depth_a: f64, + half_period_s: f64, + transitions_per_polarity: u32, + comparator_threshold_dac: u16, + }, +} + +impl Point { + pub fn acquisition_seconds(&self) -> f64 { + match self.acquisition { + Acquisition::Dark { duration_s } => duration_s, + Acquisition::Stepped { + half_period_s, + transitions_per_polarity, + .. + } => 2.0 * half_period_s * f64::from(transitions_per_polarity), + } + } +} + +fn default_sample_rate() -> u32 { + 500_000 +} +fn default_block_samples() -> u32 { + 256 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError(pub String); + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for ProtocolError {} + +fn real_id(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("tbd") && !value.contains("REPLACE") +} + +impl Protocol { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.points.is_empty() || self.points.len() > MAX_POINTS { + return Err(ProtocolError(format!( + "protocol must contain 1..={MAX_POINTS} points" + ))); + } + if !real_id(&self.camera.profile) { + return Err(ProtocolError( + "camera.profile is missing/TBD; freeze a complete host camera profile".into(), + )); + } + if self.optical.transfer_scope != "fluorescence_chain" { + return Err(ProtocolError( + "transfer_scope must be fluorescence_chain for this protocol".into(), + )); + } + if self.optical.photodiode_placement != "emission_path" { + return Err(ProtocolError( + "photodiode_placement must be emission_path".into(), + )); + } + if !(0.0..=1.0).contains(&self.optical.splitter_fraction_to_pd) + || self.optical.splitter_fraction_to_pd == 0.0 + { + return Err(ProtocolError( + "splitter_fraction_to_pd must be in (0,1]".into(), + )); + } + if !real_id(&self.optical.optical_config_id) { + return Err(ProtocolError( + "optical_config_id is missing/TBD; freeze the bench first".into(), + )); + } + if !self.gates.firmware_a2_confirmed + || !self.gates.comparator_self_test_passed + || !self.gates.camera_external_trigger_confirmed + { + return Err(ProtocolError( + "A2 firmware, comparator self-test and camera external trigger must be confirmed" + .into(), + )); + } + for (name, value) in [ + ("h4_loopback_id", self.gates.h4_loopback_id.as_str()), + ( + "h5_polarity_calibration_id", + self.gates.h5_polarity_calibration_id.as_str(), + ), + ( + "optical_edge_calibration_id", + self.gates.optical_edge_calibration_id.as_str(), + ), + ( + "local_flux_calibration_id", + self.gates.local_flux_calibration_id.as_str(), + ), + ] { + if !real_id(value) { + return Err(ProtocolError(format!("{name} is missing/TBD"))); + } + } + if self.gates.recorder_safety_limit_events_per_us == 0 { + return Err(ProtocolError( + "recorder_safety_limit_events_per_us must be pre-qualified before this protocol" + .into(), + )); + } + let c = &self.controller; + if c.v_peak_dac <= c.v_null_dac || c.comparator_hysteresis > 3 || c.min_half_us == 0 { + return Err(ProtocolError( + "controller lobe, hysteresis or min_half_us is not frozen".into(), + )); + } + for (index, p) in self.points.iter().enumerate() { + if p.label.trim().is_empty() || p.role.trim().is_empty() { + return Err(ProtocolError(format!( + "point {} needs label and role", + index + 1 + ))); + } + if p.settle_s < 0.0 { + return Err(ProtocolError(format!( + "point {} has invalid numeric values", + index + 1 + ))); + } + match p.acquisition { + Acquisition::Dark { duration_s } => { + if duration_s <= 0.0 { + return Err(ProtocolError(format!( + "point {} dark duration_s must be positive", + index + 1 + ))); + } + } + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + if !(0.0 < mean_u && mean_u <= 1.0) + || depth_a <= 0.0 + || half_period_s <= 0.0 + || transitions_per_polarity == 0 + { + return Err(ProtocolError(format!( + "point {} has invalid stepped numeric values", + index + 1 + ))); + } + if half_period_s * 1e6 < f64::from(c.min_half_us) { + return Err(ProtocolError(format!( + "point {} half-period violates min_half_us", + index + 1 + ))); + } + if !(1..=4_095).contains(&comparator_threshold_dac) { + return Err(ProtocolError(format!( + "point {} comparator_threshold_dac must be a frozen code in 1..=4095", + index + 1 + ))); + } + } + } + } + Ok(()) + } + + pub fn total_seconds(&self) -> f64 { + self.points + .iter() + .map(|p| p.settle_s + p.acquisition_seconds()) + .sum() + } +} + +pub fn parse(text: &str) -> Result { + let protocol: Protocol = toml::from_str(text).map_err(|e| ProtocolError(e.to_string()))?; + protocol.validate()?; + Ok(protocol) +} + +#[cfg(test)] +mod tests { + use super::*; + const BASE: &str = r#" +name="a2-test" +[camera] +profile="A2 qualified" +[optical] +transfer_scope="fluorescence_chain" +photodiode_placement="emission_path" +splitter_fraction_to_pd=0.5 +optical_config_id="opt-1" +[gates] +firmware_a2_confirmed=true +comparator_self_test_passed=true +camera_external_trigger_confirmed=true +h4_loopback_id="h4-1" +h5_polarity_calibration_id="h5-1" +optical_edge_calibration_id="edge-1" +local_flux_calibration_id="flux-1" +recorder_safety_limit_events_per_us=1000 +[controller] +v_null_dac=100 +v_peak_dac=1000 +comparator_hysteresis=1 +comparator_invert=false +min_half_us=100 +[[point]] +label="core" +role="core" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.5 +transitions_per_polarity=500 +settle_s=2 +comparator_threshold_dac=500 +"#; + + #[test] + fn parses_complete_fluorescence_protocol() { + assert_eq!(parse(BASE).unwrap().points.len(), 1); + } + + #[test] + fn dark_point_has_duration_but_no_step_parameters() { + let dark = BASE + .replace("acquisition_mode=\"stepped\"", "acquisition_mode=\"dark\"") + .replace("mean_u=0.3\n", "duration_s=30\n") + .replace("depth_a=0.45\n", "") + .replace("half_period_s=0.5\n", "") + .replace("transitions_per_polarity=500\n", "") + .replace("comparator_threshold_dac=500\n", ""); + let protocol = parse(&dark).unwrap(); + assert_eq!(protocol.points[0].acquisition_seconds(), 30.0); + assert!(matches!( + protocol.points[0].acquisition, + Acquisition::Dark { .. } + )); + } + + #[test] + fn tbd_gate_fails_before_hardware_moves() { + let text = BASE.replace("h4-1", "TBD"); + assert!(parse(&text).unwrap_err().0.contains("h4_loopback_id")); + } + + #[test] + fn shipped_followup_is_deliberately_not_runnable_before_bringup() { + let text = include_str!("../protocols/a2_fluorescence_chain_followup.toml"); + let error = parse(text).unwrap_err().0; + assert!( + error.contains("camera.profile") + || error.contains("optical_config_id") + || error.contains("firmware") + || error.contains("comparator_threshold_dac"), + "unexpected refusal: {error}" + ); + } +} diff --git a/plugins/stage-a-a2/src/runtime.rs b/plugins/stage-a-a2/src/runtime.rs new file mode 100644 index 0000000..3ef9d7b --- /dev/null +++ b/plugins/stage-a-a2/src/runtime.rs @@ -0,0 +1,2047 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use augur_plugin_api::{ + export_plugin, CameraConfigurationProvenanceV1, CameraConfigurationSnapshotV1, + CameraConfigurationSourceV1, EventStoreHandle, GlobalSettings, HostCommand, HostCommandOutcome, + HostCommandRequest, HostContext, HostOutput, PathDialogKind, Plugin, PluginCapabilities, + PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, PluginInput, + PluginRuntimeRole, PluginServiceOutcome, PluginServiceRequest, SensorMonitoringV1, SettingItem, + SettingKind, SettingsSchema, SettingsSection, StatusEntry, CTX_GLOBAL_SETTINGS, + CTX_SENSOR_MONITORING, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use stage_a_plugin_contract::{ + A2AcquisitionConfigV1, ClientId, ConnectionStateV1, LeaseId, ModulationCommandV1, + ModulationRequestV1, ModulationResponseV1, ModulationStateV1, PdqReceiptV1, PdqStartSpecV1, + PdqTerminationV1, PhotodiodeCommandV1, PhotodiodePlacementV1, PhotodiodeRequestV1, + PhotodiodeResponseV1, PhotodiodeSummaryV1, RequestId, RequestOutcomeV1, RunId, + SemanticRevision, CTX_STAGE_A_MODULATION_STATE_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + SERVICE_STAGE_A_MODULATION_CONTROL_V1, SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, +}; + +use crate::protocol::{self, Acquisition, Point, Protocol}; + +const ID: &str = "stage-a.a2"; +const MOD_ID: &str = "stage-a.modulation"; +const PD_ID: &str = "stage-a.photodiode"; +const TIMEOUT_MS: u64 = 20_000; +const LEASE_TTL_MS: u64 = 60_000; + +#[derive(Default, Clone, Copy)] +struct Press { + value: u64, + seen: Option, +} +impl Press { + fn accept(&mut self, v: &Value) -> bool { + if v.as_bool() == Some(true) { + self.value += 1; + self.seen = Some(self.value); + return true; + } + let Some(v) = v.as_u64() else { + return false; + }; + match self.seen { + None => { + self.seen = Some(v); + self.value = self.value.max(v); + false + } + Some(old) if v > old => { + self.seen = Some(v); + self.value = self.value.max(v); + true + } + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + ApplyCamera, + AcquireMod, + AcquirePd, + Prepare, + Settle, + StartCamera, + StartPd, + StartMod, + Recording, + StopMod, + FinalizePd, + StopCamera, + ReleasePd, + ReleaseMod, + RestoreCamera, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + Mod, + Pd, + Host, +} + +#[derive(Debug, Default, Serialize)] +struct PointEvidence { + rising_triggers: u64, + falling_triggers: u64, + expected_triggers_per_polarity: u64, + peak_events_per_us: u64, + raw_path: Option, + raw_sha256: Option, + camera_configuration_sidecar_path: Option, + sensor_monitoring_path: Option, + pdq_path: Option, + pdq_sha256: Option, + valid: bool, + failure: Option, +} + +struct Run { + protocol: Protocol, + protocol_path: String, + protocol_sha256: String, + protocol_archive_path: String, + measurement_id: String, + index: usize, + phase: Phase, + pending: Option<(PendingKind, u64, u64)>, + lease: LeaseId, + run_id: String, + deadline_ms: u64, + next_renew_ms: u64, + stop: bool, + abort_reason: Option, + mod_leased: bool, + pd_leased: bool, + camera_recording: bool, + camera_stop_attempts: u8, + pd_recording: bool, + modulation_active: bool, + camera_session_active: bool, + restore_attempts: u8, + camera_snapshot: Option, + camera_provenance: Option, + camera_readback_age_s: Option, + pause_acknowledged: bool, + last_event_bin_us: Option, + last_event_bin_count: u64, + evidence: PointEvidence, +} + +pub struct StageAA2Plugin { + enabled: bool, + role: PluginRuntimeRole, + output_folder: String, + measurement_id: String, + protocol_path: String, + start: Press, + stop: Press, + continue_press: Press, + start_pending: bool, + stop_pending: bool, + continue_pending: bool, + run: Option, + request: u64, + revision: u64, + message: String, + modulation: Option, + photodiode: Option, + settings: Option, + sensor: Option, +} + +impl Default for StageAA2Plugin { + fn default() -> Self { + Self { + enabled: true, + role: PluginRuntimeRole::LiveWorker, + output_folder: String::new(), + measurement_id: String::new(), + protocol_path: String::new(), + start: Press::default(), + stop: Press::default(), + continue_press: Press::default(), + start_pending: false, + stop_pending: false, + continue_pending: false, + run: None, + request: 0, + revision: 0, + message: "Choose an output folder and a fully qualified A2 protocol".into(), + modulation: None, + photodiode: None, + settings: None, + sensor: None, + } + } +} + +trait Control { + fn service(&mut self, request: &PluginServiceRequest); + fn host(&mut self, request: &HostCommandRequest); +} +impl Control for PluginControlContext<'_> { + fn service(&mut self, request: &PluginServiceRequest) { + let _ = self.request_service(request); + } + fn host(&mut self, request: &HostCommandRequest) { + let _ = self.request_host(request); + } +} + +impl StageAA2Plugin { + fn next_id(&mut self) -> u64 { + self.request += 1; + self.request + } + fn next_revision(&mut self) -> SemanticRevision { + self.revision += 1; + SemanticRevision(self.revision) + } + + fn blocker(&self) -> Option { + if self.role != PluginRuntimeRole::LiveWorker { + return Some("A2 hardware effects are allowed only on the live worker".into()); + } + if self.run.is_some() { + return Some("an A2 protocol is already running".into()); + } + if self.output_folder.trim().is_empty() { + return Some("choose an output folder".into()); + } + if self.protocol_path.trim().is_empty() { + return Some("choose an A2 protocol".into()); + } + let Some(settings) = self.settings.as_ref() else { + return Some("start live camera preview so host camera settings are available".into()); + }; + if settings.event_filters.stc_enabled + || settings.event_filters.trail_enabled + || settings.event_filters.erc_enabled + { + return Some("disable STC, Trail and ERC before A2".into()); + } + if !matches!( + self.modulation.as_ref().map(|s| &s.connection), + Some(ConnectionStateV1::Connected { .. }) + ) { + return Some("connect the Stage-A modulation owner".into()); + } + if !matches!( + self.photodiode.as_ref().map(|s| &s.connection), + Some(ConnectionStateV1::Connected { .. }) + ) { + return Some("connect the Stage-A photodiode owner".into()); + } + if self + .photodiode + .as_ref() + .is_none_or(|summary| summary.placement != PhotodiodePlacementV1::EmissionPath) + { + return Some("set the photodiode owner to emission_path".into()); + } + if self + .photodiode + .as_ref() + .and_then(|summary| summary.splitter_fraction) + .is_none_or(|fraction| (fraction - 0.5).abs() > 1e-6) + { + return Some("set and confirm the photodiode splitter fraction to 0.5".into()); + } + if self.sensor.and_then(|s| s.pixel_dead_time_us).is_none() { + return Some("sensor pixel-dead-time readout is missing".into()); + } + None + } + + fn begin(&mut self, control: &mut impl Control) { + if let Some(blocker) = self.blocker() { + self.message = format!("A2 refused: {blocker}"); + return; + } + let text = match std::fs::read_to_string(self.protocol_path.trim()) { + Ok(v) => v, + Err(e) => { + self.message = format!("Cannot read protocol: {e}"); + return; + } + }; + let plan = match protocol::parse(&text) { + Ok(v) => v, + Err(e) => { + self.message = format!("A2 protocol refused before hardware moved: {e}"); + return; + } + }; + if let Some(dead) = self.sensor.and_then(|s| s.pixel_dead_time_us) { + if f64::from(plan.controller.min_half_us) < 5.0 * f64::from(dead) { + self.message = format!( + "A2 refused: min_half_us={} is below 5 x sensor dead time ({dead:.2} us)", + plan.controller.min_half_us + ); + return; + } + } + let measurement_id = if self.measurement_id.trim().is_empty() { + format!("A2-{}", compact_time()) + } else { + safe(self.measurement_id.trim()) + }; + self.measurement_id = measurement_id.clone(); + let hash = hex_hash(text.as_bytes()); + let protocol_archive_path = + match archive_protocol(&self.output_folder, &measurement_id, &hash, text.as_bytes()) { + Ok(path) => path, + Err(error) => { + self.message = format!("A2 refused: cannot archive exact protocol: {error}"); + return; + } + }; + self.run = Some(Run { + protocol: plan, + protocol_path: self.protocol_path.clone(), + protocol_sha256: hash, + protocol_archive_path, + measurement_id, + index: 0, + phase: Phase::ApplyCamera, + pending: None, + lease: LeaseId::new(format!("a2-{}", now_ms())), + run_id: String::new(), + deadline_ms: 0, + next_renew_ms: now_ms() + 30_000, + stop: false, + abort_reason: None, + mod_leased: false, + pd_leased: false, + camera_recording: false, + camera_stop_attempts: 0, + pd_recording: false, + modulation_active: false, + camera_session_active: true, + restore_attempts: 0, + camera_snapshot: None, + camera_provenance: None, + camera_readback_age_s: None, + pause_acknowledged: false, + last_event_bin_us: None, + last_event_bin_count: 0, + evidence: PointEvidence::default(), + }); + self.send_host( + control, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::NamedProfile { + name: self.run.as_ref().unwrap().protocol.camera.profile.clone(), + }, + }, + ); + } + + fn point(&self) -> Option<&Point> { + self.run + .as_ref()? + .protocol + .points + .get(self.run.as_ref()?.index) + } + + fn send_mod( + &mut self, + control: &mut impl Control, + command: ModulationCommandV1, + revision: bool, + ) { + let request_id = self.next_id(); + let (lease, run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.run_id.clone(), + self.modulation.as_ref().map(|s| s.owner_instance.clone()), + ) + }; + let mut e = ModulationRequestV1::new(RequestId(request_id), ClientId::new(ID), command); + e.lease_id = Some(lease); + e.target_owner_instance = owner; + e.issued_at_unix_ms = now_ms(); + if !run_id.is_empty() { + e.run_id = Some(RunId::new(run_id)); + } + if revision { + e.requested_revision = Some(self.next_revision()); + } + control.service(&PluginServiceRequest { + request_id, + source_plugin_id: ID.into(), + target_plugin_id: MOD_ID.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + payload: serde_json::to_value(e).unwrap(), + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Mod, request_id, now_ms())); + } + + fn send_pd( + &mut self, + control: &mut impl Control, + command: PhotodiodeCommandV1, + revision: bool, + ) { + let request_id = self.next_id(); + let (lease, run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.run_id.clone(), + self.photodiode.as_ref().map(|s| s.owner_instance.clone()), + ) + }; + let mut e = PhotodiodeRequestV1::new(RequestId(request_id), ClientId::new(ID), command); + e.lease_id = Some(lease); + e.target_owner_instance = owner; + e.issued_at_unix_ms = now_ms(); + if !run_id.is_empty() { + e.run_id = Some(RunId::new(run_id)); + } + if revision { + e.requested_revision = Some(self.next_revision()); + } + control.service(&PluginServiceRequest { + request_id, + source_plugin_id: ID.into(), + target_plugin_id: PD_ID.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + payload: serde_json::to_value(e).unwrap(), + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Pd, request_id, now_ms())); + } + + fn send_host(&mut self, control: &mut impl Control, command: HostCommand) { + let id = self.next_id(); + control.host(&HostCommandRequest { + request_id: id, + command, + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Host, id, now_ms())); + } + + fn prepare(&mut self, control: &mut impl Control) { + let (p, c) = { + let r = self.run.as_ref().unwrap(); + ( + r.protocol.points[r.index].clone(), + r.protocol.controller.clone(), + ) + }; + if should_pause(&p, self.run.as_ref().unwrap().pause_acknowledged) { + self.run.as_mut().unwrap().phase = Phase::Paused; + self.message = format!("Paused before {}", p.label); + return; + } + let run = self.run.as_mut().unwrap(); + run.phase = Phase::Prepare; + run.run_id = format!( + "{}_r{:03}_{}", + run.measurement_id, + run.index + 1, + safe(&p.label) + ); + run.evidence = PointEvidence::default(); + run.last_event_bin_us = None; + run.last_event_bin_count = 0; + run.camera_stop_attempts = 0; + match p.acquisition { + Acquisition::Dark { .. } => self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 dark acquisition: force modulation safe/off".into(), + }, + true, + ), + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + let hz = 1.0 / (2.0 * half_period_s); + let cfg = A2AcquisitionConfigV1 { + mean_u_milli: (mean_u * 1000.0).round() as u32, + depth_a_milli: (depth_a * 1000.0).round() as u32, + frequency_millihz: (hz * 1000.0).round() as u64, + min_half_us: c.min_half_us, + v_null_dac: c.v_null_dac, + v_peak_dac: c.v_peak_dac, + comparator_threshold_dac, + comparator_hysteresis: c.comparator_hysteresis, + comparator_invert: c.comparator_invert, + sample_rate_hz: c.sample_rate_hz, + block_samples: c.block_samples, + emit_raw_samples: true, + emit_summary: true, + }; + self.run + .as_mut() + .unwrap() + .evidence + .expected_triggers_per_polarity = u64::from(transitions_per_polarity); + self.send_mod( + control, + ModulationCommandV1::PrepareA2 { configuration: cfg }, + true, + ); + } + } + } + + fn metadata(&self) -> BTreeMap { + let r = self.run.as_ref().unwrap(); + let p = &r.protocol.points[r.index]; + let mut m = BTreeMap::new(); + for (k, v) in [ + ("experiment", "A2".into()), + ("measurement_id", r.measurement_id.clone()), + ("protocol_path", r.protocol_path.clone()), + ("protocol_sha256", r.protocol_sha256.clone()), + ("protocol_name", r.protocol.name.clone()), + ("protocol_row", (r.index + 1).to_string()), + ("label", p.label.clone()), + ("role", p.role.clone()), + ("acquisition_mode", acquisition_mode(&p.acquisition).into()), + ("duration_s", p.acquisition_seconds().to_string()), + ("transfer_scope", r.protocol.optical.transfer_scope.clone()), + ( + "photodiode_placement", + r.protocol.optical.photodiode_placement.clone(), + ), + ( + "splitter_fraction_to_pd", + r.protocol.optical.splitter_fraction_to_pd.to_string(), + ), + ( + "optical_config_id", + r.protocol.optical.optical_config_id.clone(), + ), + ] { + m.insert(k.into(), v); + } + match p.acquisition { + Acquisition::Dark { duration_s } => { + m.insert("dark_duration_s".into(), duration_s.to_string()); + } + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + for (key, value) in [ + ("mean_u", mean_u.to_string()), + ("depth_a_commanded", depth_a.to_string()), + ("half_period_s", half_period_s.to_string()), + ( + "transitions_per_polarity", + transitions_per_polarity.to_string(), + ), + ( + "comparator_threshold_dac", + comparator_threshold_dac.to_string(), + ), + ] { + m.insert(key.into(), value); + } + } + } + m + } + + fn advance(&mut self, control: &mut impl Control) { + let done = { + let r = self.run.as_mut().unwrap(); + r.index += 1; + r.pause_acknowledged = false; + r.index >= r.protocol.points.len() || r.stop + }; + if done { + self.release_next(control); + } else { + self.prepare(control); + } + } + + fn release_next(&mut self, control: &mut impl Control) { + let Some(run) = self.run.as_ref() else { return }; + if run.pd_leased { + self.run.as_mut().unwrap().phase = Phase::ReleasePd; + self.send_pd( + control, + PhotodiodeCommandV1::ReleaseLease { + finalize_recording: true, + reason: "A2 cleanup".into(), + }, + false, + ); + } else if run.mod_leased { + self.run.as_mut().unwrap().phase = Phase::ReleaseMod; + self.send_mod( + control, + ModulationCommandV1::ReleaseLease { + safe_off: true, + reason: "A2 cleanup".into(), + }, + true, + ); + } else if run.camera_session_active { + if run.restore_attempts >= 3 { + self.message = format!( + "{}; camera restore was not confirmed after 3 attempts", + self.message + ); + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + return; + } + self.run.as_mut().unwrap().phase = Phase::RestoreCamera; + self.run.as_mut().unwrap().restore_attempts += 1; + self.send_host(control, HostCommand::RestoreCameraConfiguration); + } else { + self.finish_run(); + } + } + + fn stop_camera(&mut self, control: &mut impl Control) { + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StopCamera; + run.camera_stop_attempts = run.camera_stop_attempts.saturating_add(1); + self.send_host(control, HostCommand::StopRecording); + } + + fn finish_run(&mut self) { + let failed = self + .run + .as_ref() + .and_then(|run| run.abort_reason.as_ref()) + .is_some(); + if !failed { + self.message = "A2 protocol finished; inspect point sidecars and offline first-event distributions".into(); + } + self.run = None; + } + + fn fail(&mut self, control: &mut impl Control, reason: String) { + self.message = format!("A2 failed closed: {reason}"); + let Some(run) = self.run.as_mut() else { return }; + run.stop = true; + run.evidence.failure = Some(reason.clone()); + run.abort_reason = Some(reason); + run.pending = None; + if run.modulation_active { + run.phase = Phase::StopMod; + self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 abort".into(), + }, + true, + ); + } else if run.pd_recording { + run.phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else if run.camera_recording { + self.stop_camera(control); + } else { + self.release_next(control); + } + } + + fn drive(&mut self, control: &mut impl Control) { + if self.start_pending { + self.start_pending = false; + self.begin(control); + } + if self.stop_pending { + self.stop_pending = false; + if let Some(r) = self.run.as_mut() { + r.stop = true; + r.abort_reason = Some("operator stopped A2".into()); + } + } + if self.continue_pending { + self.continue_pending = false; + if self.run.as_ref().is_some_and(|r| r.phase == Phase::Paused) { + self.run.as_mut().unwrap().pause_acknowledged = true; + self.prepare(control); + } + } + let Some(run) = self.run.as_ref() else { return }; + if let Some((_, _, sent)) = run.pending { + if now_ms().saturating_sub(sent) > TIMEOUT_MS { + match run.phase { + Phase::ReleasePd => { + self.run.as_mut().unwrap().pd_leased = false; + self.run.as_mut().unwrap().pending = None; + self.release_next(control); + } + Phase::ReleaseMod => { + self.run.as_mut().unwrap().mod_leased = false; + self.run.as_mut().unwrap().pending = None; + self.release_next(control); + } + Phase::FinalizePd => { + let run = self.run.as_mut().unwrap(); + run.pending = None; + run.pd_recording = false; + run.evidence.failure = Some("photodiode finalize timed out".into()); + self.stop_camera(control); + } + Phase::StopCamera => { + self.run.as_mut().unwrap().pending = None; + if self.run.as_ref().unwrap().camera_stop_attempts < 3 { + self.stop_camera(control); + } else { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = + Some("camera stop timed out after 3 attempts".into()); + let _ = self.write_sidecar(); + self.release_next(control); + } + } + _ => self.fail(control, "owner/host reply timed out".into()), + } + } + return; + } + if run.stop + && !matches!( + run.phase, + Phase::StopMod + | Phase::FinalizePd + | Phase::StopCamera + | Phase::ReleasePd + | Phase::ReleaseMod + | Phase::RestoreCamera + ) + { + let reason = run + .abort_reason + .clone() + .unwrap_or_else(|| "A2 stopped".into()); + self.fail(control, reason); + return; + } + if !matches!( + run.phase, + Phase::AcquireMod | Phase::AcquirePd | Phase::ReleasePd | Phase::ReleaseMod + ) && now_ms() >= run.next_renew_ms + { + self.send_mod( + control, + ModulationCommandV1::RenewLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + return; + } + match run.phase { + Phase::Settle if now_ms() >= run.deadline_ms => { + let meta = self.metadata(); + let (run_id, base) = { + let r = self.run.as_ref().unwrap(); + ( + r.run_id.clone(), + format!("{}/{}.raw", r.measurement_id, r.run_id), + ) + }; + self.run.as_mut().unwrap().phase = Phase::StartCamera; + self.run.as_mut().unwrap().camera_recording = true; + self.send_host( + control, + HostCommand::StartRecording { + run_id, + base_path: base, + metadata: meta, + }, + ); + } + Phase::Recording if now_ms() >= run.deadline_ms || run.stop => { + if !starts_modulation(&self.point().unwrap().acquisition) { + self.run.as_mut().unwrap().phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Completed, + }, + true, + ); + } else { + self.run.as_mut().unwrap().phase = Phase::StopMod; + self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 point complete".into(), + }, + true, + ); + } + } + _ => {} + } + } + + fn accepted(&mut self, control: &mut impl Control, kind: PendingKind, payload: &Value) { + let phase = self.run.as_ref().unwrap().phase; + if kind == PendingKind::Mod { + if let Ok(response) = serde_json::from_value::(payload.clone()) { + if response.common.outcome == RequestOutcomeV1::InProgress { + self.run.as_mut().unwrap().pending = + Some((PendingKind::Mod, response.common.request_id.0, now_ms())); + return; + } + } + } + self.run.as_mut().unwrap().pending = None; + match (kind, phase) { + (PendingKind::Mod, Phase::AcquireMod) => { + let run = self.run.as_mut().unwrap(); + run.mod_leased = true; + run.phase = Phase::AcquirePd; + self.send_pd( + control, + PhotodiodeCommandV1::AcquireLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + } + (PendingKind::Pd, Phase::AcquirePd) => { + self.run.as_mut().unwrap().pd_leased = true; + self.prepare(control) + } + (PendingKind::Mod, Phase::Prepare) => { + let settle = (self.point().unwrap().settle_s * 1000.0) as u64; + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Settle; + r.deadline_ms = now_ms() + settle; + } + (PendingKind::Host, Phase::StartCamera) => { + let r = self.run.as_ref().unwrap(); + let spec = PdqStartSpecV1 { + pdq_path: format!("{}/{}.pdq", r.measurement_id, r.run_id), + sidecar_path: format!("{}/{}.pd.json", r.measurement_id, r.run_id), + expected_sample_rate_hz: Some(r.protocol.controller.sample_rate_hz), + expected_stream_epoch: self.photodiode.as_ref().map(|p| p.stream.stream_epoch), + metadata: self.metadata(), + root_dir: Some(self.output_folder.clone()), + }; + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StartPd; + run.pd_recording = true; + self.send_pd( + control, + PhotodiodeCommandV1::BeginRecording { + specification: spec, + }, + true, + ); + } + (PendingKind::Pd, Phase::StartPd) => { + if !starts_modulation(&self.point().unwrap().acquisition) { + let seconds = self.point().unwrap().acquisition_seconds(); + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Recording; + r.deadline_ms = now_ms() + (seconds * 1000.0) as u64; + } else { + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StartMod; + run.modulation_active = true; + self.send_mod(control, ModulationCommandV1::StartAcquisition, true); + } + } + (PendingKind::Mod, Phase::StartMod) => { + let seconds = self.point().unwrap().acquisition_seconds(); + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Recording; + r.deadline_ms = now_ms() + (seconds * 1000.0) as u64; + } + (PendingKind::Mod, Phase::StopMod) => { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.phase = Phase::FinalizePd; + let termination = if run.abort_reason.is_some() { + PdqTerminationV1::Aborted + } else { + PdqTerminationV1::Completed + }; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { termination }, + true, + ); + } + (PendingKind::Pd, Phase::FinalizePd) => { + let finalized = serde_json::from_value::(payload.clone()) + .ok() + .and_then(|response| match response.receipt { + Some(PdqReceiptV1::Finalized(receipt)) => Some(receipt), + _ => None, + }); + let e = &mut self.run.as_mut().unwrap().evidence; + if let Some(receipt) = finalized { + e.pdq_path = Some(receipt.pdq_path); + e.pdq_sha256 = Some(receipt.sha256.to_string()); + if !receipt.valid { + e.failure = Some("PDQ receipt invalid".into()); + } + } else { + e.failure = Some("photodiode owner returned no finalized PDQ receipt".into()); + } + self.run.as_mut().unwrap().pd_recording = false; + self.stop_camera(control); + } + (PendingKind::Pd, Phase::ReleasePd) => { + self.run.as_mut().unwrap().pd_leased = false; + self.release_next(control); + } + (PendingKind::Mod, Phase::ReleaseMod) => { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(control); + } + (PendingKind::Mod, _) => self.send_pd( + control, + PhotodiodeCommandV1::RenewLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ), + (PendingKind::Pd, _) => self.run.as_mut().unwrap().next_renew_ms = now_ms() + 30_000, + _ => {} + } + } + + fn snapshots(&mut self, inbox: &PluginControlInbox) { + for s in &inbox.snapshots { + match (s.plugin_id.as_str(), s.topic.as_str()) { + (MOD_ID, CTX_STAGE_A_MODULATION_STATE_V1) => { + if let Ok(v) = serde_json::from_value(s.payload.clone()) { + self.modulation = Some(v) + } + } + (PD_ID, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1) => { + if let Ok(v) = serde_json::from_value(s.payload.clone()) { + self.photodiode = Some(v) + } + } + _ => {} + } + } + } + + fn finish_async_mod(&mut self, control: &mut impl Control) { + let Some((PendingKind::Mod, request_id, _)) = self.run.as_ref().and_then(|r| r.pending) + else { + return; + }; + let Some(response) = self + .modulation + .as_ref() + .and_then(|s| s.last_response.as_ref()) + else { + return; + }; + if response.common.request_id.0 != request_id + || response.common.outcome == RequestOutcomeV1::InProgress + { + return; + } + if response.common.outcome == RequestOutcomeV1::Rejected { + let phase = self.run.as_ref().unwrap().phase; + if phase == Phase::ReleaseMod { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(control); + } else if phase == Phase::StopMod { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else { + self.fail( + control, + response + .common + .error + .as_ref() + .map(|e| e.message.clone()) + .unwrap_or_else(|| "modulation owner rejected A2".into()), + ); + } + return; + } + let payload = serde_json::to_value(response).unwrap_or(Value::Null); + self.accepted(control, PendingKind::Mod, &payload); + } + + fn finish_point(&mut self, control: &mut impl Control, outcome: &HostCommandOutcome) { + if let HostCommandOutcome::RecordingFinalized { + actual_raw_path, + size: _, + sha256, + duration_us: _, + } = outcome + { + let acquisition = { + let run = self.run.as_ref().unwrap(); + run.protocol.points[run.index].acquisition.clone() + }; + let e = &mut self.run.as_mut().unwrap().evidence; + e.raw_path = Some(actual_raw_path.clone()); + e.raw_sha256 = Some(sha256.clone()); + let trigger_problem = + validate_trigger_counts(&acquisition, e.rising_triggers, e.falling_triggers).err(); + if e.failure.is_none() { + e.failure = trigger_problem; + } + e.valid = e.failure.is_none(); + self.run.as_mut().unwrap().camera_recording = false; + } else if let HostCommandOutcome::RecordingPartial { reason, .. } = outcome { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = Some(format!("RAW finalized partially: {reason}")); + } else if self.run.as_ref().unwrap().camera_stop_attempts < 3 { + self.stop_camera(control); + return; + } else { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = Some(format!( + "camera stop was not confirmed after 3 attempts: {outcome:?}" + )); + } + let _ = self.write_sidecar(); + if self + .run + .as_ref() + .is_some_and(|run| run.abort_reason.is_some()) + { + self.release_next(control); + } else { + self.advance(control); + } + } + + fn host_reply( + &mut self, + control: &mut impl Control, + request_id: u64, + outcome: HostCommandOutcome, + ) { + let expected = self.run.as_ref().and_then(|run| run.pending); + if expected.is_none_or(|(kind, id, _)| kind != PendingKind::Host || id != request_id) { + return; + } + let phase = self.run.as_ref().unwrap().phase; + self.run.as_mut().unwrap().pending = None; + if phase == Phase::ApplyCamera { + match outcome { + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, + readback: _, + readback_age_s, + } => { + let requested = self.run.as_ref().unwrap().protocol.camera.profile.clone(); + let refusal = camera_configuration_refusal( + &snapshot, + &provenance, + &requested, + readback_age_s, + ); + let run = self.run.as_mut().unwrap(); + run.camera_snapshot = Some(snapshot); + run.camera_provenance = Some(provenance); + run.camera_readback_age_s = Some(readback_age_s); + if let Some(reason) = refusal { + self.fail(control, reason); + } else { + run.phase = Phase::AcquireMod; + self.send_mod( + control, + ModulationCommandV1::AcquireLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + } + } + outcome => self.fail( + control, + format!("camera profile was not applied and confirmed: {outcome:?}"), + ), + } + } else if phase == Phase::RestoreCamera { + if matches!( + outcome, + HostCommandOutcome::CameraConfigurationRestored { .. } + ) { + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + } else if self.run.as_ref().unwrap().restore_attempts < 3 { + self.run.as_mut().unwrap().restore_attempts += 1; + self.send_host(control, HostCommand::RestoreCameraConfiguration); + } else { + self.message = format!( + "{}; camera restore was not confirmed after 3 attempts: {:?}", + self.message, outcome + ); + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + } + } else if phase == Phase::StartCamera { + match outcome { + HostCommandOutcome::RecordingStarted { + actual_raw_path, .. + } => { + let evidence = &mut self.run.as_mut().unwrap().evidence; + evidence.camera_configuration_sidecar_path = + Some(camera_sidecar_path(&actual_raw_path)); + evidence.sensor_monitoring_path = + Some(sensor_monitoring_path(&actual_raw_path)); + evidence.raw_path = Some(actual_raw_path); + self.accepted(control, PendingKind::Host, &Value::Null) + } + outcome => self.fail(control, format!("camera start failed: {outcome:?}")), + } + } else if phase == Phase::StopCamera { + self.finish_point(control, &outcome) + } + } + + fn write_sidecar(&self) -> Result<(), String> { + let r = self.run.as_ref().unwrap(); + let dir = Path::new(&self.output_folder).join(&r.measurement_id); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + #[derive(Serialize)] + struct Side<'a> { + schema_version: u32, + experiment: &'static str, + protocol_path: &'a str, + protocol_sha256: &'a str, + protocol_archive_path: &'a str, + protocol_name: &'a str, + protocol_row: usize, + camera_profile: &'a str, + camera_provenance: Option<&'a CameraConfigurationProvenanceV1>, + camera_readback_age_s: Option, + optical: &'a protocol::OpticalSetup, + gates: &'a protocol::Gates, + controller: &'a protocol::ControllerSetup, + point: &'a Point, + evidence: &'a PointEvidence, + sensor_snapshot: Option, + } + #[derive(Serialize)] + struct DynamicSensorMonitoring { + pixel_dead_time_us: Option, + illumination_lux: Option, + temperature_c: Option, + age_s: f64, + } + let s = Side { + schema_version: 1, + experiment: "A2", + protocol_path: &r.protocol_path, + protocol_sha256: &r.protocol_sha256, + protocol_archive_path: &r.protocol_archive_path, + protocol_name: &r.protocol.name, + protocol_row: r.index + 1, + camera_profile: &r.protocol.camera.profile, + camera_provenance: r.camera_provenance.as_ref(), + camera_readback_age_s: r.camera_readback_age_s, + optical: &r.protocol.optical, + gates: &r.protocol.gates, + controller: &r.protocol.controller, + point: &r.protocol.points[r.index], + evidence: &r.evidence, + sensor_snapshot: self.sensor.map(|sensor| DynamicSensorMonitoring { + pixel_dead_time_us: sensor.pixel_dead_time_us, + illumination_lux: sensor.illumination_lux, + temperature_c: sensor.temperature_c, + age_s: sensor.age_s, + }), + }; + let bytes = serde_json::to_vec_pretty(&s).map_err(|e| e.to_string())?; + std::fs::write(dir.join(format!("{}.a2.json", r.run_id)), bytes).map_err(|e| e.to_string()) + } +} + +impl Plugin for StageAA2Plugin { + fn name(&self) -> &'static str { + "Stage-A A2 Latency" + } + fn description(&self) -> &'static str { + "Runs qualified optical-step latency protocols; fitting stays offline." + } + fn enabled(&self) -> bool { + self.enabled + } + fn set_enabled(&mut self, v: bool) { + self.enabled = v + } + fn set_runtime_role(&mut self, r: PluginRuntimeRole) { + self.role = r + } + fn reset(&mut self) { + self.run = None; + } + fn on_discontinuity(&mut self, _: PluginDiscontinuity) {} + fn input_kind(&self) -> PluginInput { + PluginInput::RawEvents + } + fn capabilities(&self) -> PluginCapabilities { + PluginCapabilities::default() + } + fn process_frame( + &mut self, + frame: &PluginFrame<'_>, + _: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _: &EventStoreHandle<'_>, + ) { + if let Ok(Some(v)) = context.get::(CTX_GLOBAL_SETTINGS) { + self.settings = Some(v); + } + if let Ok(Some(v)) = context.get::(CTX_SENSOR_MONITORING) { + self.sensor = Some(v); + } + if self + .run + .as_ref() + .is_some_and(|r| r.phase == Phase::Recording) + { + let r = self.run.as_mut().unwrap(); + for e in frame.events() { + let bin = e.t_us.max(0) as u64; + if r.last_event_bin_us == Some(bin) { + r.last_event_bin_count += 1; + } else { + r.last_event_bin_us = Some(bin); + r.last_event_bin_count = 1; + } + r.evidence.peak_events_per_us = + r.evidence.peak_events_per_us.max(r.last_event_bin_count); + } + for t in frame.external_triggers() { + if t.is_rising() { + r.evidence.rising_triggers += 1 + } else { + r.evidence.falling_triggers += 1 + } + } + if r.evidence.peak_events_per_us > r.protocol.gates.recorder_safety_limit_events_per_us + { + r.stop = true; + r.evidence.failure = Some(format!( + "pre-qualified recorder safety limit exceeded: {} events/us", + r.evidence.peak_events_per_us + )); + } + } + } + fn process_control(&mut self, c: &mut PluginControlContext<'_>) { + let inbox = c.inbox().clone(); + self.snapshots(&inbox); + self.finish_async_mod(c); + for reply in inbox.service_replies { + let expected = self.run.as_ref().and_then(|r| r.pending); + if expected.is_none_or(|(_, id, _)| id != reply.request_id) { + continue; + } + match reply.outcome { + PluginServiceOutcome::Accepted { payload } => { + self.accepted(c, expected.unwrap().0, &payload) + } + PluginServiceOutcome::Rejected { code, message } => { + let phase = self.run.as_ref().unwrap().phase; + if phase == Phase::ReleasePd { + self.run.as_mut().unwrap().pd_leased = false; + self.release_next(c); + } else if phase == Phase::ReleaseMod { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(c); + } else if phase == Phase::FinalizePd { + let run = self.run.as_mut().unwrap(); + run.pd_recording = false; + run.evidence.failure = Some(format!("{code}: {message}")); + self.stop_camera(c); + } else if phase == Phase::StopMod { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.evidence.failure = Some(format!("{code}: {message}")); + run.phase = Phase::FinalizePd; + self.send_pd( + c, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else { + self.fail(c, format!("{code}: {message}")) + } + } + } + } + for reply in inbox.host_replies { + self.host_reply(c, reply.request_id, reply.outcome); + } + self.drive(c); + } + fn settings_schema(&self) -> SettingsSchema { + SettingsSchema{sections:vec![SettingsSection{label:"A2 protocol".into(),description:Some("The file is validated completely before any owner lease or camera recording starts.".into()),default_open:true,items:vec![SettingItem{key:"output_folder".into(),label:"Output folder".into(),tooltip:None,kind:SettingKind::Path{dialog:PathDialogKind::Directory,default:self.output_folder.clone()}},SettingItem{key:"measurement_id".into(),label:"Measurement id".into(),tooltip:None,kind:SettingKind::Text{default:self.measurement_id.clone()}},SettingItem{key:"protocol_path".into(),label:"Protocol".into(),tooltip:None,kind:SettingKind::Path{dialog:PathDialogKind::OpenFile,default:self.protocol_path.clone()}},SettingItem{key:"run_protocol".into(),label:"Run protocol".into(),tooltip:None,kind:SettingKind::Button{enabled:true}},SettingItem{key:"continue_run".into(),label:"Continue".into(),tooltip:None,kind:SettingKind::Button{enabled:true}},SettingItem{key:"stop_protocol".into(),label:"Stop".into(),tooltip:None,kind:SettingKind::Button{enabled:true}}]}]} + } + fn get_setting(&self, k: &str) -> Option { + match k { + "output_folder" => Some(json!(self.output_folder)), + "measurement_id" => Some(json!(self.measurement_id)), + "protocol_path" => Some(json!(self.protocol_path)), + "run_protocol" => Some(json!(self.start.value)), + "continue_run" => Some(json!(self.continue_press.value)), + "stop_protocol" => Some(json!(self.stop.value)), + _ => None, + } + } + fn set_setting(&mut self, k: &str, v: Value) -> Result<(), String> { + match k { + "output_folder" => self.output_folder = v.as_str().ok_or("string required")?.into(), + "measurement_id" => self.measurement_id = v.as_str().ok_or("string required")?.into(), + "protocol_path" => self.protocol_path = v.as_str().ok_or("string required")?.into(), + "run_protocol" => { + if self.start.accept(&v) { + self.start_pending = true + } + } + "continue_run" => { + if self.continue_press.accept(&v) { + self.continue_pending = true + } + } + "stop_protocol" => { + if self.stop.accept(&v) { + self.stop_pending = true + } + } + _ => return Err(format!("unknown setting {k}")), + } + Ok(()) + } + fn status_entries(&self) -> Vec { + let mut v = vec![StatusEntry::Text(self.message.clone())]; + if let Some(r) = &self.run { + v.push(StatusEntry::Text(format!( + "{}: point {}/{} ({:?})", + r.protocol.name, + r.index + 1, + r.protocol.points.len(), + r.phase + ))); + } else if let Some(b) = self.blocker() { + v.push(StatusEntry::Text(format!("Not ready: {b}"))); + } + v + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} +fn compact_time() -> String { + now_ms().to_string() +} +fn safe(v: &str) -> String { + v.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} +fn hex_hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn archive_protocol( + output_folder: &str, + measurement_id: &str, + sha256: &str, + bytes: &[u8], +) -> Result { + let directory = Path::new(output_folder).join(measurement_id); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + let path = directory.join(format!("protocol-{sha256}.toml")); + if path.exists() { + let existing = std::fs::read(&path).map_err(|error| error.to_string())?; + if existing != bytes { + return Err("content-addressed protocol archive has different bytes".into()); + } + } else { + std::fs::write(&path, bytes).map_err(|error| error.to_string())?; + } + Ok(path.to_string_lossy().into_owned()) +} + +fn camera_sidecar_path(raw_path: &str) -> String { + Path::new(raw_path) + .with_extension("toml") + .to_string_lossy() + .into_owned() +} + +fn sensor_monitoring_path(raw_path: &str) -> String { + let path = Path::new(raw_path); + let stem = path.file_stem().unwrap_or_default().to_string_lossy(); + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{stem}.sensor-monitoring.csv")) + .to_string_lossy() + .into_owned() +} + +fn acquisition_mode(acquisition: &Acquisition) -> &'static str { + match acquisition { + Acquisition::Dark { .. } => "dark", + Acquisition::Stepped { .. } => "stepped", + } +} + +fn starts_modulation(acquisition: &Acquisition) -> bool { + matches!(acquisition, Acquisition::Stepped { .. }) +} + +fn should_pause(point: &Point, acknowledged: bool) -> bool { + point.pause_before && !acknowledged +} + +fn validate_trigger_counts( + acquisition: &Acquisition, + rising: u64, + falling: u64, +) -> Result<(), String> { + let Acquisition::Stepped { + transitions_per_polarity, + .. + } = acquisition + else { + return Ok(()); + }; + let expected = u64::from(*transitions_per_polarity); + let minimum = expected.saturating_sub(1); + let maximum = expected.saturating_add(1); + if !(minimum..=maximum).contains(&rising) || !(minimum..=maximum).contains(&falling) { + return Err(format!( + "external-trigger count implausible: expected {expected} +/- 1 per polarity, observed rising={rising}, falling={falling}" + )); + } + Ok(()) +} + +fn camera_configuration_refusal( + snapshot: &CameraConfigurationSnapshotV1, + provenance: &CameraConfigurationProvenanceV1, + requested_profile: &str, + readback_age_s: f64, +) -> Option { + if provenance.profile_name.as_deref() != Some(requested_profile) { + return Some(format!( + "host confirmed camera profile {:?}, expected {requested_profile:?}", + provenance.profile_name + )); + } + if snapshot.digital_filter.stc_enabled + || snapshot.digital_filter.trail_enabled + || snapshot.digital_filter.erc_enabled != Some(false) + { + return Some( + "applied camera profile must explicitly confirm STC, Trail and ERC off".into(), + ); + } + if !snapshot.external_trigger.enabled { + return Some("applied camera profile has EXT_TRIGGER disabled".into()); + } + if !snapshot.global.record_sensor_telemetry { + return Some("applied camera profile does not record sensor telemetry".into()); + } + if !readback_age_s.is_finite() || readback_age_s < 0.0 { + return Some("host returned an invalid sensor readback age".into()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct MockControl { + services: Vec, + hosts: Vec, + } + + impl Control for MockControl { + fn service(&mut self, request: &PluginServiceRequest) { + self.services.push(request.clone()); + } + fn host(&mut self, request: &HostCommandRequest) { + self.hosts.push(request.clone()); + } + } + + fn test_protocol() -> &'static str { + r#" +name="a2-e2e" +[camera] +profile="A2 qualified" +[optical] +transfer_scope="fluorescence_chain" +photodiode_placement="emission_path" +splitter_fraction_to_pd=0.5 +optical_config_id="opt-1" +[gates] +firmware_a2_confirmed=true +comparator_self_test_passed=true +camera_external_trigger_confirmed=true +h4_loopback_id="h4-1" +h5_polarity_calibration_id="h5-1" +optical_edge_calibration_id="edge-1" +local_flux_calibration_id="flux-1" +recorder_safety_limit_events_per_us=1000 +[controller] +v_null_dac=100 +v_peak_dac=1000 +comparator_hysteresis=1 +comparator_invert=false +min_half_us=1000 +sample_rate_hz=500000 +block_samples=256 +[[point]] +label="dark" +role="floor" +acquisition_mode="dark" +duration_s=0.001 +settle_s=0 +pause_before=true +[[point]] +label="step" +role="identification" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.001 +transitions_per_polarity=2 +comparator_threshold_dac=500 +settle_s=0 +"# + } + + fn test_folder(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("stage-a-a2-{label}-{}", now_ms())) + } + + fn ready_plugin(label: &str) -> StageAA2Plugin { + use stage_a_plugin_contract::{ + ControllerStateV1, FreshnessV1, OwnerInstanceId, PhotodiodeStreamV1, StreamIntegrityV1, + SynchronizationV1, UnsyncedReasonV1, CONTRACT_VERSION_V1, + }; + let folder = test_folder(label); + std::fs::create_dir_all(&folder).unwrap(); + let protocol_path = folder.join("protocol.toml"); + std::fs::write(&protocol_path, test_protocol()).unwrap(); + let settings = GlobalSettings { + nm_per_pixel: 1.0, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_bytes: 1 << 20, + record_sensor_telemetry: true, + roi: augur_plugin_api::RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + event_filters: augur_plugin_api::EventFiltersV1::default(), + }; + let sensor = SensorMonitoringV1 { + pixel_dead_time_us: Some(10.0), + illumination_lux: Some(0.1), + temperature_c: Some(25.0), + bias_codes: None, + age_s: 0.1, + }; + let modulation = ModulationStateV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("mod-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("test".into()), + }, + capabilities: vec![], + lease: None, + controller_state: ControllerStateV1::Configured, + active_run_id: None, + requested: None, + acknowledged: None, + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_ms(), + valid_for_ms: 60_000, + }, + calibration_id: Some("lobe-1".into()), + optical_drive: None, + }; + let photodiode = PhotodiodeSummaryV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("pd-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("test".into()), + }, + lease: None, + active_run_id: None, + requested_revision: None, + acknowledged_revision: None, + stream: PhotodiodeStreamV1 { + stream_epoch: 1, + sample_range: None, + sample_rate_hz: Some(500_000), + latest_adc_code: Some(1000), + integrity: StreamIntegrityV1::default(), + level: None, + }, + data_dir: Some(folder.to_string_lossy().into_owned()), + active_recording: None, + last_finalized_recording: None, + optical_summary: None, + optical_unavailable: None, + placement: PhotodiodePlacementV1::EmissionPath, + splitter_fraction: Some(0.5), + dark_reference: None, + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_ms(), + valid_for_ms: 60_000, + }, + }; + StageAA2Plugin { + output_folder: folder.to_string_lossy().into_owned(), + protocol_path: protocol_path.to_string_lossy().into_owned(), + measurement_id: format!("A2-{label}"), + settings: Some(settings), + sensor: Some(sensor), + modulation: Some(modulation), + photodiode: Some(photodiode), + ..StageAA2Plugin::default() + } + } + + fn qualified_camera() -> ( + CameraConfigurationSnapshotV1, + CameraConfigurationProvenanceV1, + ) { + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraDigitalFilterV1, CameraExternalTriggerV1, + CameraGlobalSettingsV1, RoiV1, + }; + ( + CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1::default(), + roi: RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 0, + trail_enabled: false, + erc_enabled: Some(false), + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 0, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 1.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_mib: 512, + preview_interval_ms: 16, + point_cloud_interval_ms: 50, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + }, + CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A2 qualified".into()), + schema_version: 1, + profile_revision: Some(1), + sha256: "ab".repeat(32), + }, + ) + } + + fn applied_camera_outcome() -> HostCommandOutcome { + let (snapshot, provenance) = qualified_camera(); + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + } + } + + fn finalized_pd_payload(request_id: u64, run_id: &str) -> Value { + use stage_a_plugin_contract::{ + OwnerInstanceId, PdqFinalizedReceiptV1, ResponseCommonV1, Sha256V1, StreamIntegrityV1, + CONTRACT_VERSION_V1, + }; + serde_json::to_value(PhotodiodeResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: RequestId(request_id), + owner_instance: OwnerInstanceId::new("pd-test"), + run_id: Some(RunId::new(run_id)), + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_ms()), + error: None, + }, + receipt: Some(PdqReceiptV1::Finalized(PdqFinalizedReceiptV1 { + run_id: RunId::new(run_id), + pdq_path: format!("{run_id}.pdq"), + sidecar_path: format!("{run_id}.pd.json"), + opened_at_unix_ms: now_ms(), + finalized_at_unix_ms: now_ms(), + file_size_bytes: 1, + sha256: Sha256V1::parse("cd".repeat(32)).unwrap(), + frames_written: 1, + sample_frames_written: 1, + sample_range: None, + sample_rate_hz: Some(500_000), + segment_count: 1, + integrity: StreamIntegrityV1::default(), + termination: PdqTerminationV1::Completed, + valid: true, + })), + }) + .unwrap() + } + + fn paused_point() -> Point { + Point { + label: "shutter".into(), + role: "dark".into(), + settle_s: 0.0, + pause_before: true, + acquisition: Acquisition::Dark { duration_s: 30.0 }, + } + } + + #[test] + fn continue_acknowledges_a_pause_once_for_the_current_point() { + let point = paused_point(); + assert!(should_pause(&point, false)); + assert!(!should_pause(&point, true)); + } + + #[test] + fn dark_points_do_not_require_external_triggers() { + assert!(validate_trigger_counts(&paused_point().acquisition, 0, 0).is_ok()); + assert!(!starts_modulation(&paused_point().acquisition)); + } + + #[test] + fn stepped_trigger_counts_must_match_the_commanded_count() { + let acquisition = Acquisition::Stepped { + mean_u: 0.3, + depth_a: 0.45, + half_period_s: 1.0, + transitions_per_polarity: 100, + comparator_threshold_dac: 500, + }; + assert!(validate_trigger_counts(&acquisition, 100, 99).is_ok()); + assert!(validate_trigger_counts(&acquisition, 2, 2).is_err()); + } + + #[test] + fn camera_profile_requires_explicit_erc_off() { + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraDigitalFilterV1, CameraExternalTriggerV1, + CameraGlobalSettingsV1, RoiV1, + }; + let mut snapshot = CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1::default(), + roi: RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 0, + trail_enabled: false, + erc_enabled: None, + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 0, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 1.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_mib: 512, + preview_interval_ms: 16, + point_cloud_interval_ms: 50, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + }; + let provenance = CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A2 qualified".into()), + schema_version: 1, + profile_revision: Some(1), + sha256: "ab".repeat(32), + }; + assert!( + camera_configuration_refusal(&snapshot, &provenance, "A2 qualified", 0.1) + .unwrap() + .contains("ERC") + ); + snapshot.digital_filter.erc_enabled = Some(false); + assert!( + camera_configuration_refusal(&snapshot, &provenance, "A2 qualified", 0.1).is_none() + ); + } + + #[test] + fn end_to_end_runs_paused_dark_then_stepped_and_restores_camera() { + let mut plugin = ready_plugin("e2e-success"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::ApplyCameraConfiguration { .. } + )); + let apply_id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply_id, applied_camera_outcome()); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::AcquireMod); + + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Paused); + plugin.continue_pending = true; + plugin.drive(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Prepare); + let dark_prepare: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + dark_prepare.command, + ModulationCommandV1::StopAcquisition { .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + let camera_start = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + camera_start, + HostCommandOutcome::RecordingStarted { + actual_raw_path: "/tmp/a2-dark.raw".into(), + started_at: "now".into(), + }, + ); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Recording); + assert!(!plugin.run.as_ref().unwrap().modulation_active); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::FinalizePd); + let pd_id = plugin.run.as_ref().unwrap().pending.unwrap().1; + let run_id = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.accepted( + &mut control, + PendingKind::Pd, + &finalized_pd_payload(pd_id, &run_id), + ); + let stop_camera_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + stop_camera_id, + HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/tmp/a2-dark.raw".into(), + size: 1, + sha256: "ef".repeat(32), + duration_us: 1_000, + }, + ); + + assert_eq!(plugin.run.as_ref().unwrap().index, 1); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Prepare); + let stepped_prepare: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + stepped_prepare.command, + ModulationCommandV1::PrepareA2 { .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + let camera_start = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + camera_start, + HostCommandOutcome::RecordingStarted { + actual_raw_path: "/tmp/a2-step.raw".into(), + started_at: "now".into(), + }, + ); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::StartMod); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + { + let run = plugin.run.as_mut().unwrap(); + run.evidence.rising_triggers = 2; + run.evidence.falling_triggers = 2; + run.deadline_ms = 0; + } + plugin.drive(&mut control); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + let pd_id = plugin.run.as_ref().unwrap().pending.unwrap().1; + let run_id = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.accepted( + &mut control, + PendingKind::Pd, + &finalized_pd_payload(pd_id, &run_id), + ); + let stop_camera_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + stop_camera_id, + HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/tmp/a2-step.raw".into(), + size: 1, + sha256: "12".repeat(32), + duration_us: 4_000, + }, + ); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleasePd); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleaseMod); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::RestoreCamera); + let restore_id = control.hosts.last().unwrap().request_id; + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::RestoreCameraConfiguration + )); + plugin.host_reply( + &mut control, + restore_id, + HostCommandOutcome::CameraConfigurationRestored { + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + }, + ); + assert!(plugin.run.is_none()); + } + + #[test] + fn failure_before_pd_lease_releases_owned_resources_then_restores_camera() { + let mut plugin = ready_plugin("e2e-failure"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply_id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply_id, applied_camera_outcome()); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert!(plugin.run.as_ref().unwrap().mod_leased); + assert!(!plugin.run.as_ref().unwrap().pd_leased); + + plugin.fail(&mut control, "photodiode lease rejected".into()); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleaseMod); + let release: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + release.command, + ModulationCommandV1::ReleaseLease { safe_off: true, .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::RestoreCamera); + let restore_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + restore_id, + HostCommandOutcome::CameraConfigurationRestored { + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + }, + ); + assert!(plugin.run.is_none()); + assert!(plugin.message.contains("failed closed")); + } +} + +export_plugin!(StageAA2Plugin); From 5e8ce6817c8cae717f8f89bb563e923ba6b3c461 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:43 +0200 Subject: [PATCH 09/11] =?UTF-8?q?feat(stage-a-a4):=20=E2=9C=A8=20add=20the?= =?UTF-8?q?=20contrast-threshold=20survey=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A4 walks a protocol of diff_on/diff_off pairs at one fixed optical condition and records a RAW file at each, with enough provenance to read an event rate against a threshold setting months later. Every point is confirmed against the sensor's own bias readback before it records. A point whose absolute codes disagree with the row, or whose confirming reading is missing or older than the change, is skipped: a threshold file that is wrong about which codes were live on the die is wrong in a way nobody can detect afterwards. Refusals and flags are split on purpose. The event filters being off, the codes being confirmed and the file being whole are gates, because without them the number means nothing. Temperature drift, illumination drift and event rate are flags that mark a point and keep it — whether a 2 °C drift invalidated a threshold is a judgement to make later with the file in hand, and a runner that discarded the point would have thrown away the evidence for making it. A limit whose quantity could not be measured is flagged rather than passed. The survey runs on the host's generic camera-configuration session: it opens by having the host confirm the configuration the bench is on, and builds every point by cloning that snapshot and setting exactly two fields, so fo, hpf, refr, the ROI, the mask and the trigger are carried forward unchanged. A test asserts that equality field by field. Refs ADR 035, augur-rs ADR 037. --- Cargo.toml | 1 + docs/adr/035-stage-a-a4-threshold-survey.md | 110 + docs/features/stage-a-a4.md | 154 + plugins/stage-a-a4/Cargo.toml | 23 + plugins/stage-a-a4/README.md | 140 + plugins/stage-a-a4/plugin.toml | 14 + plugins/stage-a-a4/protocols/example.csv | 17 + plugins/stage-a-a4/protocols/example.toml | 47 + .../protocols/example_asymmetric.csv | 17 + plugins/stage-a-a4/src/lib.rs | 19 + plugins/stage-a-a4/src/protocol.rs | 1006 +++++ plugins/stage-a-a4/src/qc.rs | 355 ++ plugins/stage-a-a4/src/runtime.rs | 3304 +++++++++++++++++ plugins/stage-a-a4/src/sidecar.rs | 226 ++ 14 files changed, 5433 insertions(+) create mode 100644 docs/adr/035-stage-a-a4-threshold-survey.md create mode 100644 docs/features/stage-a-a4.md create mode 100644 plugins/stage-a-a4/Cargo.toml create mode 100644 plugins/stage-a-a4/README.md create mode 100644 plugins/stage-a-a4/plugin.toml create mode 100644 plugins/stage-a-a4/protocols/example.csv create mode 100644 plugins/stage-a-a4/protocols/example.toml create mode 100644 plugins/stage-a-a4/protocols/example_asymmetric.csv create mode 100644 plugins/stage-a-a4/src/lib.rs create mode 100644 plugins/stage-a-a4/src/protocol.rs create mode 100644 plugins/stage-a-a4/src/qc.rs create mode 100644 plugins/stage-a-a4/src/runtime.rs create mode 100644 plugins/stage-a-a4/src/sidecar.rs diff --git a/Cargo.toml b/Cargo.toml index d37e297..f33ef05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "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", diff --git a/docs/adr/035-stage-a-a4-threshold-survey.md b/docs/adr/035-stage-a-a4-threshold-survey.md new file mode 100644 index 0000000..0dcd5a3 --- /dev/null +++ b/docs/adr/035-stage-a-a4-threshold-survey.md @@ -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` diff --git a/docs/features/stage-a-a4.md b/docs/features/stage-a-a4.md new file mode 100644 index 0000000..84c6118 --- /dev/null +++ b/docs/features/stage-a-a4.md @@ -0,0 +1,154 @@ +# Stage-A A4 Threshold Survey + +- **Crate:** `plugins/stage-a-a4` (`augur-plugin-stage-a-a4`), id `stage-a.a4` +- **Status:** built — protocol runner, per-point bias confirmation, QC summary, + sidecars and run receipt +- **Design:** [ADR 035](../adr/035-stage-a-a4-threshold-survey.md) (a threshold + point is only real if the sensor confirms it), + [augur-rs ADR 037](https://github.com/muthmann/augur-rs/blob/main/docs/adr/037-host-owned-camera-profiles-and-plugin-configuration-sessions.md) + (the generic camera-configuration session it runs on), + [ADR 027](../adr/027-stage-a-a1-declarative-protocols.md) (the protocol shape + it follows), [ADR 028](../adr/028-stage-a-sensor-readout-travels-with-the-measurement.md) + (the telemetry compaction it shares with A1), + [ADR 031](../adr/031-evesmlm-plugins-share-a-types-crate.md) (why the shared + code lives in `stage-a-plugin-contract`) +- **User docs:** [`plugins/stage-a-a4/README.md`](../../plugins/stage-a-a4/README.md) + +## Purpose + +A4 measures the IMX636's contrast threshold. At one **fixed optical +condition** it steps `diff_on`/`diff_off` through a protocol, records a RAW +file at each point, and writes the provenance needed to read an event rate +against a threshold setting months later. + +Done by hand this is two sliders, an Apply, a wait and a Record, dozens of +times, with the codes that actually reached the sensor written down in a +notebook. A4 makes it one button — and, more to the point, makes every file +able to prove which absolute bias codes were live on the die while it was +written. + +## The host half + +The plugin interface could not change a camera bias at all. `HostCommand` had +two verbs, `start_recording` and `stop_recording`. + +A4 was first built against a third verb written for it, `apply_biases`, which +was two fields wide precisely so a threshold survey could not disturb anything +else. That verb is gone. The host must not carry plugin- or experiment-specific +commands (augur-rs ADR 037), so what A4 runs on now is the same **generic +camera-configuration session** every other plugin uses: + +- **`ApplyCameraConfiguration`** takes a *complete* configuration, from one of + three sources: the configuration the host is currently on, a named host-owned + profile, or an immutable snapshot the plugin supplies. The first call in a + session makes the host preserve the pre-session state. +- **The reply is a readback, not an acknowledgement.** The host applies the + configuration, waits for a monitoring read taken *after* the change, and + answers `CameraConfigurationApplied` with the confirmed snapshot, its + provenance and hash, the absolute bias codes, and the age of the reading. A + reading older than the change cannot confirm it; one that never arrives, or + that disagrees, is a rejection. +- **`RestoreCameraConfiguration`** puts the preserved state back. Only the + plugin that opened the session may restore it. +- **The host owns the interlocks** a plugin cannot enforce: no change during a + recording or its finalization, none while STC or Trail is on, none without a + camera, and offsets inside `-85..=140`. +- `GlobalSettings` gained `event_filters` (`stc_enabled`, `trail_enabled`, + `erc_enabled`) so a survey can refuse *before* it starts and record the state + as provenance. This host has no event-rate controller, so `erc_enabled` is + always `false` — the field exists so "ERC was off" is a recorded fact rather + than an omission. + +**The narrowness moved from the wire into A4.** What the old verb made +impossible, A4 now has to keep true itself: it opens each run with +`ApplyCameraConfiguration { Current }`, keeps the snapshot the host confirms, +and builds every point by cloning that snapshot and setting exactly two fields. +`fo`, `hpf`, `refr`, the ROI, the mask and the trigger are copied forward +unchanged rather than being unreachable, and a test asserts a point's +configuration equals the baseline field by field except for the two biases. + +The control plane crosses the FFI as JSON, so this was wire-additive: +`PLUGIN_ABI_VERSION` stayed at 6. + +## Per point + +1. **Apply** the baseline snapshot with the row's two offsets set on it. + Nothing else is changed. +2. **Confirm** the absolute codes against the sensor's own readback, and that + the reading is fresh. Codes that disagree, or a missing or stale reading, + **skip the point** — recording it anyway produces a file that is wrong in a + way nobody can detect later. +3. **Settle** for `settle_s`, *and* wait for a monitoring sample newer than the + settle. Waiting out a duration proves only that time passed. +4. **Record** for `duration_s`, counting ON/OFF events. +5. **Check** the receipt — size, hash, duration, clean finalization — and write + the sidecar. A partial or truncated file is never counted as recorded. + +Afterwards, on Stop, and on any abort, the configuration the survey found is +put back with `RestoreCameraConfiguration`; the run does not close until that +restore is answered. + +## Refusals vs flags + +The split is the design decision worth knowing (ADR 035). + +**Hard**, because without them a threshold number means nothing: the event +filters being off, the bias codes being confirmed, a readback existing at all, +and the file being whole. + +**Flags**, recorded and carried but never blocking: `max_temperature_drift_c`, +`max_illumination_drift_percent`, `max_event_rate`. Whether a 2 °C drift +invalidated a point is a judgement to make later with the file in hand. + +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 limit nobody checked, which looks like a verified result. + +## Protocols + +CSV (one row per recording) or TOML (blocks and ranges), in +`plugins/stage-a-a4/protocols/`, all three shipped examples parsed as test +fixtures. Only `diff_on` and `diff_off` are required; columns are found by +header name. `repeats` expands to N separate recordings, each with its own file +and QC verdict, because the drift between two repeats is part of what the +survey measures. `pause_before` stops for a filter change or a dark cap and +waits for **Continue** — once per row, since the filter is already changed by +the time a second repeat starts. + +A TOML block expands to the **product** of its two axes, which is the 2D +threshold map; a symmetric sweep is a set of specific pairs, so it belongs in +the CSV form. Axis ranges are `{ min, max, step }` rather than a point count: +bias codes are integers, and an invented spacing would not be a code the +operator chose. + +Everything checkable is checked on the button press — a bad file is refused +before the first bias moves. + +## What lands on disk + +Under `//`: the RAW, the host's own camera/bias +sidecar, the A4 sidecar (`.a4.toml`), the compacted sensor telemetry +(`.sensor.json`), a **copy of the protocol**, and +`.protocol-status.toml` with its hash and the per-row execution status. + +Failed points get sidecars too. Fields the sensor could not report are absent, +never `0` (ADR 022). Sensor lux is labelled in the file as a stability +indicator, not a calibrated optical power. + +## Shared code + +A1's CSV record splitter and sensor-telemetry compactor moved into +`stage-a-plugin-contract` as `csv` and `telemetry`, with the schema tag +parameterised (`stage-a.a1.sensor.v1` / `stage-a.a4.sensor.v1`). Both workflows +gather the same host-written CSV, and a second copy would drift the moment the +host adds a column. A plugin crate can never depend on another plugin crate — +they all export `augur_plugin_vtable` (ADR 031) — so the shared home is the +vtable-free contract crate. + +## Not built + +- No live threshold curve. The rates in the panel are a stability quicklook + counted from preview frames; the authoritative counts come from the RAW + offline, which is where the threshold fit belongs. +- No automated filter changes. A filter wheel would remove the pauses, but it + is a device nobody owns yet. diff --git a/plugins/stage-a-a4/Cargo.toml b/plugins/stage-a-a4/Cargo.toml new file mode 100644 index 0000000..bcefe04 --- /dev/null +++ b/plugins/stage-a-a4/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "augur-plugin-stage-a-a4" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A A4 contrast-threshold survey: bias points recorded unattended against a sensor readback" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde.workspace = true +serde_json.workspace = true +# The same digest the host reports for a RAW file, so the protocol copy carries +# a hash that means the same thing as every other hash in the folder. +sha2 = "0.10" +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[lints.rust] +unsafe_code = "forbid" diff --git a/plugins/stage-a-a4/README.md b/plugins/stage-a-a4/README.md new file mode 100644 index 0000000..ff165e1 --- /dev/null +++ b/plugins/stage-a-a4/README.md @@ -0,0 +1,140 @@ +# Stage-A A4 — contrast-threshold survey + +Reproducible `diff_on`/`diff_off` threshold measurements on the IMX636. At one +fixed optical condition, A4 walks a protocol of bias pairs and records a RAW +file at each, with enough provenance to read an event rate against a threshold +setting months later. + +- **Crate:** `augur-plugin-stage-a-a4` · **id:** `stage-a.a4` · **phase:** `raw_events` +- **Host commands:** `start_recording`, `stop_recording`, + `apply_camera_configuration`, `restore_camera_configuration` +- **Requires:** augur-rs with the generic camera-configuration session + (augur-rs ADR 037) + +## What it changes, and what it does not + +A4 changes **two registers**: `diff_on` and `diff_off`. The host offers one +generic verb that carries a whole configuration — there is no A4-specific +command — so the freeze on `fo`, `hpf`, `refr`, the ROI and the pixel mask is +kept by A4 itself: it opens the run by asking the host to confirm the +configuration the bench is on, and every point is that confirmed snapshot with +exactly two fields changed. A test asserts the equality field by field. + +They are recorded with every point exactly as A4 found them. + +The optical condition is yours. A4 never drives the Teensy and never touches a +filter; a row that needs one says `pause_before` and waits for a button. + +## Per point + +1. Clone the configuration the session confirmed, set its `diff_on`/`diff_off`, + and send it back as `ApplyCameraConfiguration`. +2. **Confirm against the sensor's own readback** that the absolute codes on the + die are `factory_default + offset`. A point whose codes disagree, or whose + confirming reading is missing or older than the change, is skipped — it is + not measuring what the protocol says it measures. +3. Settle for `settle_s`, *and* wait for a monitoring sample newer than the + settle. A settle that produced no fresh telemetry is not a settle. +4. Record for `duration_s`, counting ON/OFF events. +5. Check the receipt — size, hash, duration, clean finalization — and write the + sidecar. A partial or truncated file is never counted as recorded. + +On completion, on Stop, and on any abort, the configuration the bench was on +before the survey is put back — the host preserved it when the session opened, +so `RestoreCameraConfiguration` returns the whole state, not only the two +biases. The run does not close until that restore is answered. + +## Refusals vs flags + +The split is deliberate. + +**Hard refusals** (nothing runs, or the point is skipped) are the things that +make a threshold number mean anything at all: + +- STC, Trail or ERC enabled — they discard events before streaming, which is + the quantity being counted +- no bias readback available — the method's central claim would be uncheckable +- bias codes that disagree with the row, or a stale confirming reading +- no output folder, an unreadable or invalid protocol +- a partial, empty, unhashed or truncated recording + +**Flags** (the point is recorded and kept, and marked) are the bench-stability +limits: `max_temperature_drift_c`, `max_illumination_drift_percent`, +`max_event_rate`. Whether a 2 °C drift invalidated a point is a judgement to +make later with the file in hand — a runner that discarded it would have thrown +away the evidence for making it. + +A limit whose quantity could never be measured is flagged too, not passed: a +camera with no temperature readback must not silently report every point as +within a drift limit nobody checked. + +## Protocols + +`protocols/` ships three worked examples, all parsed as test fixtures. + +**CSV — one row per recording.** Only `diff_on` and `diff_off` are required; +columns are found by header name, so their order does not matter. + +```csv +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +threshold-01,LP647+BP700,-20,-10,60,5,2 +threshold-02,LP647+BP700,0,0,60,5,2 +``` + +Optional: `pause_before`, `max_temperature_drift_c`, +`max_illumination_drift_percent`, `max_event_rate`, `filter_id`, `flux_id`. + +**TOML — blocks and ranges.** A block expands to the **product** of its two +axes, which is the 2D threshold map. A symmetric sweep is a set of specific +pairs, not a product, so it belongs in the CSV form. + +```toml +[defaults] +duration_s = 60 +settle_s = 5 + +[[block]] +diff_on = { min = -20, max = 20, step = 10 } +diff_off = 0 +``` + +Bias values are **offsets** around the per-unit factory trim — the same numbers +the host settings panel shows. The absolute codes come from the sensor. + +Everything checkable is checked on the button press: a bad file is refused +before the first bias moves, not at 3 a.m. on row 37. + +## What lands on disk + +Under `//`: + +| File | What it is | +|---|---| +| `.raw` | the recording, gathered out of the host's capture folder | +| `.toml` | the host's own camera/bias sidecar, travelling with its RAW | +| `.a4.toml` | the A4 sidecar — protocol row, bias codes, bench conditions, QC | +| `.sensor.json` | the host's telemetry, compacted column-wise | +| `.csv` \| `.toml` | a copy of the protocol that ran | +| `.protocol-status.toml` | its hash, and the per-row execution status | + +A sidecar is written for failed points too — the record of a failed point is +the reason the survey has a hole in it. + +Fields the sensor could not report are **absent**, never `0`: a die temperature +of 0 °C and "this camera has no temperature readback" are opposite facts. + +Sensor lux is a stability indicator, not a calibrated optical power. The +sidecar says so in the file. + +## Notes + +- QC rates are counted from the preview frames the plugin observed, over + `counted_seconds`; compare that against `recorded_duration_s` for the + coverage. The authoritative counts come from the RAW offline. +- `Restore biases` is the recovery path for a run that could not restore them + itself. During a run it is refused. It asks the host to put its own preserved + configuration back, so a host that was reloaded mid-survey has no session + left and refuses — that gap is not yet closed. + +See [`docs/features/stage-a-a4.md`](../../docs/features/stage-a-a4.md) and +[ADR 035](../../docs/adr/035-stage-a-a4-threshold-survey.md). diff --git a/plugins/stage-a-a4/plugin.toml b/plugins/stage-a-a4/plugin.toml new file mode 100644 index 0000000..4b8d394 --- /dev/null +++ b/plugins/stage-a-a4/plugin.toml @@ -0,0 +1,14 @@ +id = "stage-a.a4" +name = "Stage-A A4 Threshold" +version = "0.1.0" +description = "Stage-A A4 contrast-threshold survey: steps diff_on/diff_off through a protocol at one fixed optical condition, confirming every point against the sensor's own bias readback before it records." +domain = "stage-a" +library = "augur_plugin_stage_a_a4" +phase = "raw_events" +min_augur_version = "1.0.0" +host_commands = [ + "start_recording", + "stop_recording", + "apply_camera_configuration", + "restore_camera_configuration", +] diff --git a/plugins/stage-a-a4/protocols/example.csv b/plugins/stage-a-a4/protocols/example.csv new file mode 100644 index 0000000..96b27fe --- /dev/null +++ b/plugins/stage-a-a4/protocols/example.csv @@ -0,0 +1,17 @@ +# Stage-A A4 — symmetric threshold sweep at one optical condition. +# +# One row per recording. Only diff_on and diff_off are required; everything +# else falls back (60 s, 5 s settle, recorded once, no QC limits). +# +# diff_on/diff_off are OFFSETS around the sensor's per-unit factory trim — the +# same numbers the host settings panel shows. The absolute codes on the die are +# read back from the sensor and written into every sidecar. +# +# Symmetric pairs belong in a CSV: each row is one exact (on, off) pair. Use +# the TOML form when you want the product of two axes instead. +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats,max_temperature_drift_c,max_illumination_drift_percent +threshold-01,LP647+BP700,-20,-20,60,5,2,2.0,5.0 +threshold-02,LP647+BP700,-10,-10,60,5,2,2.0,5.0 +threshold-03,LP647+BP700,0,0,60,5,2,2.0,5.0 +threshold-04,LP647+BP700,10,10,60,5,2,2.0,5.0 +threshold-05,LP647+BP700,20,20,60,5,2,2.0,5.0 diff --git a/plugins/stage-a-a4/protocols/example.toml b/plugins/stage-a-a4/protocols/example.toml new file mode 100644 index 0000000..746b984 --- /dev/null +++ b/plugins/stage-a-a4/protocols/example.toml @@ -0,0 +1,47 @@ +# Stage-A A4 — the 2D threshold map, written as blocks and ranges. +# +# A block expands to the PRODUCT of its two bias axes: every diff_on against +# every diff_off, walked diff_on outermost. That is the right shape for mapping +# the ON/OFF threshold plane. +# +# For a symmetric sweep — diff_on and diff_off moving together — use the CSV +# form instead: those are specific pairs, not a product. + +name = "a4-threshold-map" + +# Defaults every block inherits unless it says otherwise. +[defaults] +duration_s = 60 +settle_s = 5 +repeats = 1 +optical_state = "LP647+BP700" +filter_id = "F-700" +max_temperature_drift_c = 2.0 +max_illumination_drift_percent = 5.0 + +# A coarse map: 5 × 5 = 25 recordings. +[[block]] +name = "coarse-map" +diff_on = { min = -20, max = 20, step = 10 } +diff_off = { min = -20, max = 20, step = 10 } + +# A finer look around the symmetric centre, recorded twice each. +[[block]] +name = "centre-detail" +diff_on = [-4, -2, 0, 2, 4] +diff_off = [0] +repeats = 2 +duration_s = 90 + +# A dark reference at the end. `pause_before` stops once, before the block, so +# the cap goes on and every point under it runs unattended. +[[block]] +name = "dark-reference" +diff_on = [0, 20] +diff_off = [0, 20] +optical_state = "dark cap" +filter_id = "none" +pause_before = true +duration_s = 120 +settle_s = 10 +max_event_rate = 50000 diff --git a/plugins/stage-a-a4/protocols/example_asymmetric.csv b/plugins/stage-a-a4/protocols/example_asymmetric.csv new file mode 100644 index 0000000..c708202 --- /dev/null +++ b/plugins/stage-a-a4/protocols/example_asymmetric.csv @@ -0,0 +1,17 @@ +# Stage-A A4 — ON and OFF thresholds moved independently, with a filter change +# partway through. +# +# `pause_before` stops the run and waits for Continue, once per row: the filter +# is already changed by the time a second repeat starts. Use it for anything +# the operator has to do by hand — a filter swap, a dark cap, a flux change. +# +# `filter_id` and `flux_id` are free text carried into every sidecar, so two +# points can be shown to have been taken under the same optical condition +# rather than merely assumed to be. +label,optical_state,filter_id,flux_id,diff_on,diff_off,duration_s,settle_s,repeats,pause_before,max_event_rate +on-low,LP647+BP700,F-700,flux-A,-20,0,60,5,1,no,2000000 +on-high,LP647+BP700,F-700,flux-A,20,0,60,5,1,no,2000000 +off-low,LP647+BP700,F-700,flux-A,0,-20,60,5,1,no,2000000 +off-high,LP647+BP700,F-700,flux-A,0,20,60,5,1,no,2000000 +dark-01,dark cap,none,dark,0,0,120,10,1,yes,50000 +dark-02,dark cap,none,dark,20,20,120,10,1,no,50000 diff --git a/plugins/stage-a-a4/src/lib.rs b/plugins/stage-a-a4/src/lib.rs new file mode 100644 index 0000000..b5392c7 --- /dev/null +++ b/plugins/stage-a-a4/src/lib.rs @@ -0,0 +1,19 @@ +//! Stage-A A4: reproducible contrast-threshold measurements on the IMX636. +//! +//! At one fixed optical condition, A4 walks a protocol of `diff_on`/`diff_off` +//! bias pairs, confirms each against the sensor's own readback before it +//! records, and writes a RAW file per point with the provenance needed to read +//! an event rate against a threshold setting months later. +//! +//! This crate owns no hardware. Biases are changed through the host's generic +//! camera-configuration session (augur-rs ADR 037), the only way a plugin can +//! touch the sensor. That session carries a whole configuration, so keeping the +//! survey to two registers is A4's own job: it clones the configuration the +//! host confirmed when the run opened, and changes exactly two fields. + +pub mod protocol; +pub mod qc; +mod runtime; +mod sidecar; + +pub use runtime::StageAA4Plugin; diff --git a/plugins/stage-a-a4/src/protocol.rs b/plugins/stage-a-a4/src/protocol.rs new file mode 100644 index 0000000..2e6c989 --- /dev/null +++ b/plugins/stage-a-a4/src/protocol.rs @@ -0,0 +1,1006 @@ +//! Declarative threshold protocols: a file naming the bias points to record, +//! expanded into the flat list the runner walks. +//! +//! A4 holds the optical condition still and sweeps the sensor. Every row states +//! one `(diff_on, diff_off)` pair, how long to record it, how long to settle +//! first, and how many times to repeat it — plus the QC limits that row is +//! judged against and the optical state it was taken under, so the file is a +//! complete description of the survey six months later. +//! +//! ## CSV — one row per recording +//! +//! ```csv +//! label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +//! threshold-01,LP647+BP700,-20,-10,60,5,2 +//! threshold-02,LP647+BP700,0,0,60,5,2 +//! threshold-03,LP647+BP700,20,20,60,5,2 +//! ``` +//! +//! Only `diff_on` and `diff_off` are required. Columns are found **by header +//! name**, so their order does not matter and any of the optional ones may be +//! left out entirely. Optional columns: `label`, `optical_state`, `duration_s`, +//! `settle_s`, `repeats`, `pause_before`, `max_temperature_drift_c`, +//! `max_illumination_drift_percent`, `max_event_rate`, `filter_id`, `flux_id`. +//! +//! ## TOML — blocks and ranges +//! +//! ```toml +//! name = "a4-threshold" +//! +//! [defaults] +//! duration_s = 60 +//! settle_s = 5 +//! repeats = 2 +//! optical_state = "LP647+BP700" +//! +//! [[block]] +//! name = "on-sweep" +//! diff_on = { min = -20, max = 20, step = 10 } +//! diff_off = 0 +//! ``` +//! +//! An axis is a single value, an explicit list, or an inclusive +//! `{ min, max, step }` range (`step` defaults to 1). Bias codes are integers, +//! so a range is stated by its step rather than by a point count — asking for +//! "5 points from -20 to 20" would have to invent a spacing, and the one it +//! invented would not be a code the operator chose. +//! +//! A block expands to the **product** of its two axes, which is the 2D +//! threshold map. A symmetric sweep — where `diff_on` and `diff_off` move +//! together — is a set of specific pairs, not a product, so it belongs in the +//! CSV form where each pair is written out. +//! +//! ## Ordering +//! +//! Points come out in file order, `diff_on` outermost within a block, and each +//! row's repeats consecutively. Nothing is reordered: a threshold survey drifts +//! with the bench, so the order the operator wrote is the order that has to be +//! defensible against the temperature log. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +use stage_a_plugin_contract::csv::split_line; + +/// Hard ceiling on the recordings one protocol may expand to. Repeats multiply, +/// so an operator who typed one zero too many should be told on the button +/// press rather than after the bench has spent a night on it. +pub const MAX_POINTS: usize = 4_096; + +/// Bias offset window the host accepts (and the IMX636 driver behind it). +/// Checked here so a bad value names its own line instead of surfacing as a +/// rejected command on point 37. +pub const BIAS_OFFSET_RANGE: (i64, i64) = (-85, 140); +const DURATION_RANGE: (i64, i64) = (1, 3_600); +const SETTLE_RANGE: (f64, f64) = (0.0, 600.0); +const REPEATS_RANGE: (i64, i64) = (1, 100); + +/// Stability limits one point is judged against. +/// +/// Every limit is optional and every one is a **flag, not a gate**: a breach is +/// recorded in the point's sidecar and the run summary, and the recording is +/// still kept. A threshold survey that silently dropped its drifting points +/// would hide exactly the evidence needed to decide whether the drift mattered. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct QcLimits { + /// Maximum |T − T_start| over the recording, in °C. + pub max_temperature_drift_c: Option, + /// Maximum |lux − lux_start| / lux_start over the recording, in percent. + pub max_illumination_drift_percent: Option, + /// Maximum mean event rate over the recording, in events per second. + pub max_event_rate: Option, +} + +impl QcLimits { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// One recording the protocol asks for, with every parameter resolved. +#[derive(Debug, Clone, PartialEq)] +pub struct A4Point { + /// Where this row came from — a CSV `label` or the `[[block]]` name — for + /// the status line, the file stem and the sidecar. + pub label: String, + /// Free text naming the optical condition: filters, dark cap, flux. A4 + /// never changes it; it is recorded so two points can be shown to have been + /// taken under the same one. + pub optical_state: String, + /// Bias offsets around the factory trim, as the host settings panel + /// expresses them. The absolute codes are read back from the sensor. + pub diff_on: i64, + pub diff_off: i64, + pub duration_s: i64, + pub settle_s: f64, + /// Which repeat of its row this is, and how many there are: `(1, 2)` is the + /// first of two. `(1, 1)` for a row recorded once. + pub repeat: (u32, u32), + /// Stop and wait for the operator before this point — a filter change or a + /// dark cap. The run does not continue until Continue is pressed. + pub pause_before: bool, + pub limits: QcLimits, + pub filter_id: String, + pub flux_id: String, +} + +impl A4Point { + /// Filename fragment identifying this point inside the measurement folder. + /// + /// Signed offsets are rendered with an explicit `p`/`m` rather than a + /// leading `-`, so a stem never starts a shell argument with a dash and + /// sorts the way it reads. + pub fn tag(&self) -> String { + let mut tag = format!( + "on{}_off{}", + signed_tag(self.diff_on), + signed_tag(self.diff_off) + ); + if self.repeat.1 > 1 { + tag.push_str(&format!("_r{:02}", self.repeat.0)); + } + tag + } +} + +fn signed_tag(value: i64) -> String { + if value < 0 { + format!("m{}", value.unsigned_abs()) + } else { + format!("p{value}") + } +} + +/// A parsed protocol: what to record, in order. +#[derive(Debug, Clone, PartialEq)] +pub struct Protocol { + pub name: String, + pub points: Vec, +} + +impl Protocol { + /// Distinct values on each bias axis, for the summary shown before starting. + pub fn axis_counts(&self) -> (usize, usize) { + let count = |values: Vec| { + let mut values = values; + values.sort_unstable(); + values.dedup(); + values.len() + }; + ( + count(self.points.iter().map(|point| point.diff_on).collect()), + count(self.points.iter().map(|point| point.diff_off).collect()), + ) + } + + /// Total bench time the protocol asks for, settling included. The bias + /// handshake per point is not in this number, so it reads a little short. + pub fn total_seconds(&self) -> f64 { + self.points + .iter() + .map(|point| point.duration_s as f64 + point.settle_s) + .sum() + } + + /// Whether any row asks the operator to intervene. A survey with a pause in + /// it cannot be left alone, and the panel should say so before it starts. + pub fn has_pauses(&self) -> bool { + self.points.iter().any(|point| point.pause_before) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ProtocolError { + Toml(String), + /// A named row, column, block or default is unusable, with the reason. + Invalid { + what: String, + detail: String, + }, + Empty, + TooManyPoints(usize), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(detail) => write!(f, "the protocol file is not valid TOML: {detail}"), + Self::Invalid { what, detail } => write!(f, "{what}: {detail}"), + Self::Empty => f.write_str( + "the protocol has no points to record — add at least one row with a diff_on \ + and a diff_off", + ), + Self::TooManyPoints(count) => write!( + f, + "the protocol expands to {count} recordings, past the {MAX_POINTS} limit — \ + narrow an axis, lower the repeats, or split it into several files" + ), + } + } +} + +impl std::error::Error for ProtocolError {} + +fn strip_bom(text: &str) -> &str { + text.strip_prefix('\u{feff}').unwrap_or(text) +} + +/// Parses a protocol, choosing the form from the file extension. +pub fn parse_file(path: &str, text: &str) -> Result { + let is_csv = std::path::Path::new(path) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("csv")); + if is_csv { + parse_csv(text) + } else { + parse_toml(text) + } +} + +// ---- CSV form -------------------------------------------------------------- + +const CSV_REQUIRED: [&str; 2] = ["diff_on", "diff_off"]; +const CSV_OPTIONAL: [&str; 10] = [ + "label", + "optical_state", + "duration_s", + "settle_s", + "repeats", + "pause_before", + "max_temperature_drift_c", + "max_illumination_drift_percent", + "max_event_rate", + "filter_id", +]; + +/// Parses the row-per-recording CSV form. +/// +/// Columns are located **by header name**, so their order does not matter and a +/// column can be left out entirely — which is what keeps a file working after +/// someone drags a column in a spreadsheet. Blank lines and `#` comments are +/// skipped so a file can explain itself, and errors carry the **file line +/// number** because that is what an editor and a spreadsheet both show. +pub fn parse_csv(text: &str) -> Result { + let mut header: Option> = None; + let mut points = Vec::new(); + + // `lines()` already absorbs CRLF; the BOM is what it leaves behind. + for (offset, raw) in strip_bom(text).lines().enumerate() { + let line_no = offset + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields = split_line(raw); + + let Some(columns) = header.as_ref() else { + let columns: Vec = fields + .iter() + .map(|field| field.trim().to_ascii_lowercase()) + .collect(); + for required in CSV_REQUIRED { + if !columns.iter().any(|column| column == required) { + return Err(ProtocolError::Invalid { + what: format!("line {line_no}: the header"), + detail: format!( + "has no '{required}' column. Required: {}. Optional: {}, flux_id", + CSV_REQUIRED.join(", "), + CSV_OPTIONAL.join(", ") + ), + }); + } + } + header = Some(columns); + continue; + }; + + let cell = |name: &str| -> Option<&str> { + let index = columns.iter().position(|column| column == name)?; + fields.get(index).map(|field| field.trim()) + }; + let invalid = |name: &str, detail: String| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail, + }; + let integer = |name: &str, range: (i64, i64)| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + let value: i64 = raw + .parse() + .map_err(|_| invalid(name, format!("'{raw}' is not a whole number")))?; + check_range_i64(name, value, range).map(Some) + }; + let float = |name: &str, range: (f64, f64)| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + let value: f64 = raw + .parse() + .map_err(|_| invalid(name, format!("'{raw}' is not a number")))?; + check_range_f64(name, value, range).map(Some) + }; + + let diff_on = integer("diff_on", BIAS_OFFSET_RANGE)? + .ok_or_else(|| invalid("diff_on", "is empty".into()))?; + let diff_off = integer("diff_off", BIAS_OFFSET_RANGE)? + .ok_or_else(|| invalid("diff_off", "is empty".into()))?; + let duration_s = integer("duration_s", DURATION_RANGE)?.unwrap_or(60); + let settle_s = float("settle_s", SETTLE_RANGE)?.unwrap_or(5.0); + let repeats = integer("repeats", REPEATS_RANGE)?.unwrap_or(1) as u32; + let pause_before = parse_bool(cell("pause_before").unwrap_or("")) + .ok_or_else(|| invalid("pause_before", "is not yes/no".into()))?; + + let limits = QcLimits { + max_temperature_drift_c: float("max_temperature_drift_c", (0.0, 1_000.0))?, + max_illumination_drift_percent: float( + "max_illumination_drift_percent", + (0.0, 100_000.0), + )?, + max_event_rate: float("max_event_rate", (0.0, 1e12))?, + }; + + let label = cell("label").unwrap_or("").trim().to_owned(); + let label = if label.is_empty() { + format!("row{}", points.len() + 1) + } else { + label + }; + push_repeats( + &mut points, + A4Point { + label, + optical_state: cell("optical_state").unwrap_or("").to_owned(), + diff_on, + diff_off, + duration_s, + settle_s, + repeat: (1, repeats), + pause_before, + limits, + filter_id: cell("filter_id").unwrap_or("").to_owned(), + flux_id: cell("flux_id").unwrap_or("").to_owned(), + }, + repeats, + )?; + } + + if header.is_none() { + return Err(ProtocolError::Invalid { + what: "the protocol file".into(), + detail: format!( + "has no header line. The first line that is not blank or a # comment must name \ + the columns, at least: {}", + CSV_REQUIRED.join(", ") + ), + }); + } + if points.is_empty() { + return Err(ProtocolError::Empty); + } + Ok(Protocol { + name: "protocol".to_owned(), + points, + }) +} + +/// A repeated row is N recordings, not one recorded N times: each gets its own +/// file, its own sidecar and its own QC verdict, because the drift between two +/// repeats is one of the things the survey is measuring. +fn push_repeats( + points: &mut Vec, + point: A4Point, + repeats: u32, +) -> Result<(), ProtocolError> { + for index in 1..=repeats.max(1) { + let mut repeat = point.clone(); + repeat.repeat = (index, repeats.max(1)); + // Only the first repeat stops for the operator: the filter is already + // changed by the time the second one starts. + repeat.pause_before = point.pause_before && index == 1; + points.push(repeat); + if points.len() > MAX_POINTS { + return Err(ProtocolError::TooManyPoints(points.len())); + } + } + Ok(()) +} + +fn parse_bool(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "" | "0" | "no" | "false" | "n" => Some(false), + "1" | "yes" | "true" | "y" => Some(true), + _ => None, + } +} + +fn check_range_i64(name: &str, value: i64, range: (i64, i64)) -> Result { + if value < range.0 || value > range.1 { + return Err(ProtocolError::Invalid { + what: name.to_owned(), + detail: format!("{value} is outside the supported {}..={}", range.0, range.1), + }); + } + Ok(value) +} + +fn check_range_f64(name: &str, value: f64, range: (f64, f64)) -> Result { + if !value.is_finite() || value < range.0 || value > range.1 { + return Err(ProtocolError::Invalid { + what: name.to_owned(), + detail: format!("{value} is outside the supported {}..={}", range.0, range.1), + }); + } + Ok(value) +} + +// ---- TOML form ------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct ProtocolDoc { + #[serde(default)] + name: Option, + #[serde(default)] + defaults: Defaults, + #[serde(default, rename = "block")] + blocks: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct Defaults { + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + repeats: Option, + #[serde(default)] + optical_state: Option, + #[serde(default)] + filter_id: Option, + #[serde(default)] + flux_id: Option, + #[serde(default)] + max_temperature_drift_c: Option, + #[serde(default)] + max_illumination_drift_percent: Option, + #[serde(default)] + max_event_rate: Option, +} + +#[derive(Debug, Deserialize)] +struct BlockDoc { + #[serde(default)] + name: Option, + diff_on: Axis, + diff_off: Axis, + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + repeats: Option, + #[serde(default)] + optical_state: Option, + #[serde(default)] + filter_id: Option, + #[serde(default)] + flux_id: Option, + #[serde(default)] + pause_before: Option, + #[serde(default)] + max_temperature_drift_c: Option, + #[serde(default)] + max_illumination_drift_percent: Option, + #[serde(default)] + max_event_rate: Option, +} + +/// One bias axis: a single value, an explicit list, or an inclusive range. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Axis { + One(i64), + List(Vec), + Range { + min: i64, + max: i64, + step: Option, + }, +} + +impl Axis { + /// Expand to the values to visit, in the order they are recorded. + fn values(&self, what: &str) -> Result, ProtocolError> { + let invalid = |detail: String| ProtocolError::Invalid { + what: what.to_owned(), + detail, + }; + let values = match self { + Self::One(value) => vec![*value], + Self::List(values) => { + if values.is_empty() { + return Err(invalid("is an empty list".into())); + } + values.clone() + } + Self::Range { min, max, step } => { + let step = step.unwrap_or(1); + if step <= 0 { + return Err(invalid(format!("step {step} must be positive"))); + } + if max < min { + return Err(invalid(format!("max {max} is below min {min}"))); + } + // Inclusive of `min`, and of `max` when the step lands on it. + // A range whose step overshoots simply stops early rather than + // silently recording a point the file never named. + let mut values = Vec::new(); + let mut value = *min; + while value <= *max { + values.push(value); + value += step; + } + values + } + }; + for value in &values { + check_range_i64(what, *value, BIAS_OFFSET_RANGE)?; + } + Ok(values) + } +} + +/// Parses the block/range TOML form and expands it into points. +pub fn parse_toml(text: &str) -> Result { + let doc: ProtocolDoc = + toml::from_str(strip_bom(text)).map_err(|error| ProtocolError::Toml(error.to_string()))?; + + let mut points = Vec::new(); + // Blocks may be named or not; unnamed ones get a stable positional name so + // every recording can still say which part of the protocol it belongs to. + let mut seen_names: BTreeMap = BTreeMap::new(); + for (index, block) in doc.blocks.iter().enumerate() { + let base = block + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| format!("block{}", index + 1)); + // Two blocks sharing a name would put two different sets of points in + // one namespace; keep them distinguishable rather than refusing. + let occurrence = seen_names.entry(base.clone()).or_insert(0); + *occurrence += 1; + let name = if *occurrence == 1 { + base + } else { + format!("{base}#{occurrence}") + }; + + let duration_s = check_range_i64( + &format!("block '{name}': duration_s"), + block.duration_s.or(doc.defaults.duration_s).unwrap_or(60), + DURATION_RANGE, + )?; + let settle_s = check_range_f64( + &format!("block '{name}': settle_s"), + block.settle_s.or(doc.defaults.settle_s).unwrap_or(5.0), + SETTLE_RANGE, + )?; + let repeats = check_range_i64( + &format!("block '{name}': repeats"), + block.repeats.or(doc.defaults.repeats).unwrap_or(1), + REPEATS_RANGE, + )? as u32; + + let limits = QcLimits { + max_temperature_drift_c: block + .max_temperature_drift_c + .or(doc.defaults.max_temperature_drift_c), + max_illumination_drift_percent: block + .max_illumination_drift_percent + .or(doc.defaults.max_illumination_drift_percent), + max_event_rate: block.max_event_rate.or(doc.defaults.max_event_rate), + }; + let optical_state = block + .optical_state + .clone() + .or_else(|| doc.defaults.optical_state.clone()) + .unwrap_or_default(); + let filter_id = block + .filter_id + .clone() + .or_else(|| doc.defaults.filter_id.clone()) + .unwrap_or_default(); + let flux_id = block + .flux_id + .clone() + .or_else(|| doc.defaults.flux_id.clone()) + .unwrap_or_default(); + + let on_values = block.diff_on.values(&format!("block '{name}': diff_on"))?; + let off_values = block + .diff_off + .values(&format!("block '{name}': diff_off"))?; + // `diff_on` outermost: a block is the 2D threshold map, walked one ON + // row at a time. + let mut first_of_block = true; + for diff_on in &on_values { + for diff_off in &off_values { + push_repeats( + &mut points, + A4Point { + label: name.clone(), + optical_state: optical_state.clone(), + diff_on: *diff_on, + diff_off: *diff_off, + duration_s, + settle_s, + repeat: (1, repeats), + // A block-level pause is about the optical condition + // the whole block shares, so it stops once, before the + // block, not before each of its points. + pause_before: block.pause_before.unwrap_or(false) && first_of_block, + limits, + filter_id: filter_id.clone(), + flux_id: flux_id.clone(), + }, + repeats, + )?; + first_of_block = false; + } + } + } + + if points.is_empty() { + return Err(ProtocolError::Empty); + } + Ok(Protocol { + name: doc + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "protocol".to_owned()), + points, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CSV: &str = "\ +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +threshold-01,LP647+BP700,-20,-10,60,5,2 +threshold-02,LP647+BP700,0,0,60,5,2 +threshold-03,LP647+BP700,20,20,60,5,2 +"; + + #[test] + fn the_requirements_example_parses_into_six_recordings() { + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + // Three rows, two repeats each. + assert_eq!(protocol.points.len(), 6); + assert_eq!(protocol.points[0].label, "threshold-01"); + assert_eq!(protocol.points[0].diff_on, -20); + assert_eq!(protocol.points[0].diff_off, -10); + assert_eq!(protocol.points[0].duration_s, 60); + assert_eq!(protocol.points[0].optical_state, "LP647+BP700"); + assert!((protocol.points[0].settle_s - 5.0).abs() < f64::EPSILON); + assert_eq!(protocol.axis_counts(), (3, 3)); + } + + #[test] + fn repeats_are_separate_recordings_numbered_in_order() { + // Each repeat is its own file and its own QC verdict — the drift + // between two repeats is part of what the survey measures. + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + let first_row: Vec<(u32, u32)> = protocol.points[..2] + .iter() + .map(|point| point.repeat) + .collect(); + assert_eq!(first_row, vec![(1, 2), (2, 2)]); + assert_eq!(protocol.points[0].tag(), "onm20_offm10_r01"); + assert_eq!(protocol.points[1].tag(), "onm20_offm10_r02"); + } + + #[test] + fn a_row_recorded_once_carries_no_repeat_suffix() { + let csv = "diff_on,diff_off\n0,0\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points[0].repeat, (1, 1)); + assert_eq!(protocol.points[0].tag(), "onp0_offp0"); + } + + #[test] + fn a_negative_offset_never_starts_a_stem_with_a_dash() { + let csv = "diff_on,diff_off\n-85,140\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + let tag = protocol.points[0].tag(); + assert_eq!(tag, "onm85_offp140"); + assert!(!tag.starts_with('-'), "{tag}"); + } + + #[test] + fn only_the_two_bias_columns_are_required() { + let csv = "diff_off,diff_on\n5,-5\n"; + let protocol = parse_file("a4.csv", csv).expect("column order must not matter"); + assert_eq!(protocol.points[0].diff_on, -5); + assert_eq!(protocol.points[0].diff_off, 5); + // The rest fall back rather than refusing. + assert_eq!(protocol.points[0].duration_s, 60); + assert_eq!(protocol.points[0].repeat, (1, 1)); + assert!(protocol.points[0].limits.is_empty()); + } + + #[test] + fn a_missing_bias_column_names_itself_and_the_line() { + let csv = "label,diff_on\nx,0\n"; + let error = parse_file("a4.csv", csv).expect_err("diff_off is required"); + let text = error.to_string(); + assert!(text.contains("diff_off"), "{text}"); + assert!(text.contains("line 1"), "{text}"); + } + + #[test] + fn an_offset_the_host_would_reject_is_refused_at_parse_time() { + // The point of checking here: the operator finds out on the button + // press, not when point 37 is rejected at 3 a.m. + let csv = "diff_on,diff_off\n0,200\n"; + let error = parse_file("a4.csv", csv).expect_err("200 is out of range"); + let text = error.to_string(); + assert!(text.contains("diff_off"), "{text}"); + assert!(text.contains("-85..=140"), "{text}"); + } + + #[test] + fn optional_qc_limits_are_read_per_row() { + let csv = "diff_on,diff_off,max_temperature_drift_c,max_event_rate\n\ + 0,0,1.5,250000\n\ + 10,10,,\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points[0].limits.max_temperature_drift_c, Some(1.5)); + assert_eq!(protocol.points[0].limits.max_event_rate, Some(250_000.0)); + // An empty cell is "no limit", not zero — a zero limit would flag + // every point. + assert!(protocol.points[1].limits.is_empty()); + } + + #[test] + fn a_pause_stops_once_per_row_not_once_per_repeat() { + // The filter is already changed by the time the second repeat starts. + let csv = "diff_on,diff_off,repeats,pause_before\n0,0,3,yes\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + let pauses: Vec = protocol.points.iter().map(|p| p.pause_before).collect(); + assert_eq!(pauses, vec![true, false, false]); + assert!(protocol.has_pauses()); + } + + #[test] + fn comments_and_blank_lines_let_a_file_explain_itself() { + let csv = "# threshold survey, 2026-08-07\n\ + \n\ + diff_on,diff_off\n\ + # the symmetric points\n\ + 0,0\n\ + \n\ + 10,10\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points.len(), 2); + } + + #[test] + fn a_spreadsheet_bom_and_crlf_do_not_hide_the_first_column() { + let csv = "\u{feff}diff_on,diff_off\r\n-20,-10\r\n"; + let protocol = parse_file("a4.csv", csv).expect("BOM + CRLF CSV"); + assert_eq!(protocol.points.len(), 1); + assert_eq!(protocol.points[0].diff_on, -20); + } + + const TOML: &str = r#" +name = "a4-map" + +[defaults] +duration_s = 30 +settle_s = 2.0 +optical_state = "LP647+BP700" +max_temperature_drift_c = 2.0 + +[[block]] +name = "on-sweep" +diff_on = { min = -20, max = 20, step = 10 } +diff_off = 0 + +[[block]] +name = "corner" +diff_on = [30, 40] +diff_off = [30, 40] +repeats = 2 +"#; + + #[test] + fn a_block_expands_to_the_product_of_its_two_bias_axes() { + let protocol = parse_toml(TOML).expect("valid protocol"); + assert_eq!(protocol.name, "a4-map"); + // 5 × 1 + (2 × 2) × 2 repeats + assert_eq!(protocol.points.len(), 5 + 8); + } + + #[test] + fn a_range_is_inclusive_and_walks_diff_on_outermost() { + let protocol = parse_toml(TOML).expect("valid protocol"); + let sweep: Vec = protocol + .points + .iter() + .filter(|point| point.label == "on-sweep") + .map(|point| point.diff_on) + .collect(); + assert_eq!(sweep, vec![-20, -10, 0, 10, 20]); + + let corner: Vec<(i64, i64)> = protocol + .points + .iter() + .filter(|point| point.label == "corner" && point.repeat.0 == 1) + .map(|point| (point.diff_on, point.diff_off)) + .collect(); + assert_eq!(corner, vec![(30, 30), (30, 40), (40, 30), (40, 40)]); + } + + #[test] + fn block_values_override_the_defaults_they_do_not_replace_them() { + let protocol = parse_toml(TOML).expect("valid protocol"); + let corner = protocol + .points + .iter() + .find(|point| point.label == "corner") + .expect("corner block"); + // `repeats` was overridden; everything else still comes from defaults. + assert_eq!(corner.repeat, (1, 2)); + assert_eq!(corner.duration_s, 30); + assert_eq!(corner.optical_state, "LP647+BP700"); + assert_eq!(corner.limits.max_temperature_drift_c, Some(2.0)); + } + + #[test] + fn two_blocks_with_one_name_stay_distinguishable() { + let text = r#" +[[block]] +name = "sweep" +diff_on = 0 +diff_off = 0 + +[[block]] +name = "sweep" +diff_on = 10 +diff_off = 10 +"#; + let protocol = parse_toml(text).expect("valid protocol"); + let labels: Vec<&str> = protocol + .points + .iter() + .map(|point| point.label.as_str()) + .collect(); + assert_eq!(labels, vec!["sweep", "sweep#2"]); + } + + #[test] + fn an_empty_protocol_says_what_to_add() { + assert_eq!(parse_toml("name = \"x\"\n"), Err(ProtocolError::Empty)); + let error = parse_file("a4.csv", "diff_on,diff_off\n").expect_err("no rows"); + assert_eq!(error, ProtocolError::Empty); + } + + #[test] + fn a_protocol_too_large_to_run_is_refused_before_the_bench_starts() { + let text = "[[block]]\ndiff_on = { min = -85, max = 140, step = 1 }\n\ + diff_off = { min = -85, max = 140, step = 1 }\n"; + let error = parse_toml(text).expect_err("226 × 226 is far past the limit"); + assert!(matches!(error, ProtocolError::TooManyPoints(_))); + } + + #[test] + fn a_reversed_or_zero_step_range_names_the_block_it_is_in() { + let text = "[[block]]\nname = \"bad\"\ndiff_on = { min = 20, max = 0 }\ndiff_off = 0\n"; + let error = parse_toml(text).expect_err("max below min"); + let message = error.to_string(); + assert!(message.contains("bad"), "{message}"); + assert!(message.contains("diff_on"), "{message}"); + + let text = "[[block]]\ndiff_on = { min = 0, max = 20, step = 0 }\ndiff_off = 0\n"; + let error = parse_toml(text).expect_err("zero step"); + assert!(error.to_string().contains("step"), "{error}"); + } + + #[test] + fn total_bench_time_counts_every_repeat() { + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + // 6 recordings × (60 s + 5 s) + assert!((protocol.total_seconds() - 390.0).abs() < f64::EPSILON); + } +} + +#[cfg(test)] +mod shipped_protocol_tests { + use super::*; + + /// The files under `protocols/` are what an operator copies to start from. + /// A broken example is worse than none, so they are parsed as fixtures. + fn shipped(name: &str) -> Protocol { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/protocols/"); + let full = format!("{path}{name}"); + let text = std::fs::read_to_string(&full) + .unwrap_or_else(|error| panic!("{name} must be readable: {error}")); + parse_file(&full, &text).unwrap_or_else(|error| panic!("{name} must parse: {error}")) + } + + #[test] + fn the_symmetric_example_is_five_pairs_recorded_twice() { + let protocol = shipped("example.csv"); + assert_eq!(protocol.points.len(), 10); + // Symmetric by construction — that is what the file is demonstrating. + for point in &protocol.points { + assert_eq!(point.diff_on, point.diff_off, "{}", point.label); + } + assert!(!protocol.has_pauses()); + } + + #[test] + fn the_asymmetric_example_pauses_once_for_the_dark_cap() { + let protocol = shipped("example_asymmetric.csv"); + assert_eq!(protocol.points.len(), 6); + let paused: Vec<&str> = protocol + .points + .iter() + .filter(|point| point.pause_before) + .map(|point| point.label.as_str()) + .collect(); + assert_eq!(paused, vec!["dark-01"]); + assert_eq!( + protocol.points[4].limits.max_event_rate, + Some(50_000.0), + "the dark rows carry a much tighter rate limit" + ); + } + + #[test] + fn the_toml_example_expands_to_the_map_it_documents() { + let protocol = shipped("example.toml"); + assert_eq!(protocol.name, "a4-threshold-map"); + // 5×5 coarse + 5×1 centre × 2 repeats + 2×2 dark + assert_eq!(protocol.points.len(), 25 + 10 + 4); + // diff_on: the coarse five plus the four centre-detail values; + // diff_off: the coarse five, which the other blocks stay inside. + assert_eq!(protocol.axis_counts(), (9, 5)); + // Every point inherits the defaults it does not override. + let coarse = protocol + .points + .iter() + .find(|point| point.label == "coarse-map") + .expect("coarse block"); + assert_eq!(coarse.optical_state, "LP647+BP700"); + assert_eq!(coarse.duration_s, 60); + assert_eq!(coarse.limits.max_temperature_drift_c, Some(2.0)); + } + + #[test] + fn every_shipped_protocol_stays_inside_the_hosts_bias_range() { + // The parser enforces this, so a passing parse is the assertion; this + // states the intent so the reason is not lost. + for name in ["example.csv", "example_asymmetric.csv", "example.toml"] { + for point in shipped(name).points { + assert!( + (BIAS_OFFSET_RANGE.0..=BIAS_OFFSET_RANGE.1).contains(&point.diff_on), + "{name}: {}", + point.label + ); + assert!( + (BIAS_OFFSET_RANGE.0..=BIAS_OFFSET_RANGE.1).contains(&point.diff_off), + "{name}: {}", + point.label + ); + } + } + } +} diff --git a/plugins/stage-a-a4/src/qc.rs b/plugins/stage-a-a4/src/qc.rs new file mode 100644 index 0000000..6da4511 --- /dev/null +++ b/plugins/stage-a-a4/src/qc.rs @@ -0,0 +1,355 @@ +//! Quality control for one threshold point: what the sensor did while it was +//! recorded, and whether the bench held still enough to believe it. +//! +//! Everything here is pure. The runner feeds it counts and readings; it decides +//! nothing about the recording itself. +//! +//! The limits are **flags, not gates**. A point that drifts is recorded, kept, +//! and marked — because 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 thrown away the evidence for making it. + +use crate::protocol::QcLimits; + +/// Event counts and rates over one recording. +/// +/// Rates are over the **recorded wall-clock duration**, not over the analysis +/// window, so they are comparable between points of different lengths. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct RateSummary { + pub on_events: u64, + pub off_events: u64, + /// Seconds the counts were accumulated over. + pub seconds: f64, +} + +impl RateSummary { + pub fn total_events(&self) -> u64 { + self.on_events.saturating_add(self.off_events) + } + + /// Events per second, or `None` when nothing was counted over a real + /// interval. `None` is not zero: a point whose events were never seen must + /// not report a rate of 0 Hz, which is a measurement. + pub fn on_rate_hz(&self) -> Option { + self.rate(self.on_events) + } + + pub fn off_rate_hz(&self) -> Option { + self.rate(self.off_events) + } + + pub fn total_rate_hz(&self) -> Option { + self.rate(self.total_events()) + } + + /// Share of events that were ON, in `0.0..=1.0`. The quantity a threshold + /// survey is usually read through — an asymmetric `diff_on`/`diff_off` pair + /// should move it. + pub fn on_fraction(&self) -> Option { + let total = self.total_events(); + (total > 0).then(|| self.on_events as f64 / total as f64) + } + + fn rate(&self, count: u64) -> Option { + (self.seconds > 0.0).then(|| count as f64 / self.seconds) + } +} + +/// How far a monitoring channel moved between the start and the end of a +/// recording. `None` for a channel the sensor could not report — absent, never +/// zero, because "no reading" and "no drift" are opposite facts. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Drift { + /// |T_end − T_start| in °C. + pub temperature_c: Option, + /// |lux_end − lux_start| / lux_start × 100. + pub illumination_percent: Option, +} + +/// One channel's readings at the two ends of a recording. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Endpoints { + pub start: Option, + pub end: Option, +} + +impl Endpoints { + fn absolute_change(&self) -> Option { + match (self.start, self.end) { + (Some(start), Some(end)) => Some((end as f64 - start as f64).abs()), + _ => None, + } + } + + fn relative_change_percent(&self) -> Option { + match (self.start, self.end) { + // A relative drift against a zero baseline is not a percentage of + // anything. Report nothing rather than an infinity. + (Some(start), Some(end)) if start.abs() > f32::EPSILON => { + Some(((end as f64 - start as f64) / start as f64).abs() * 100.0) + } + _ => None, + } + } +} + +pub fn drift(temperature: Endpoints, illumination: Endpoints) -> Drift { + Drift { + temperature_c: temperature.absolute_change(), + illumination_percent: illumination.relative_change_percent(), + } +} + +/// The verdict on one recorded point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QcStatus { + /// Every limit the row set was met. + Pass, + /// The row set no limits, so there was nothing to check. Distinct from + /// `Pass`: an unchecked point must not read as a verified one. + NotEvaluated, + /// At least one limit was exceeded. The recording is kept; the reasons are + /// carried into the sidecar and the run summary verbatim. + Flagged(Vec), +} + +impl QcStatus { + /// Short tag for the sidecar and the status table. + pub fn as_str(&self) -> &'static str { + match self { + Self::Pass => "pass", + Self::NotEvaluated => "not_evaluated", + Self::Flagged(_) => "flagged", + } + } + + pub fn flags(&self) -> &[String] { + match self { + Self::Flagged(flags) => flags, + _ => &[], + } + } + + pub fn is_flagged(&self) -> bool { + matches!(self, Self::Flagged(_)) + } +} + +/// Judge a recorded point against the limits its protocol row set. +/// +/// A limit whose quantity could not be measured is **not** a pass and **not** a +/// breach — it is recorded as a flag saying the check could not be made, so a +/// survey run on a camera with no temperature readback does not silently report +/// every point as within a drift limit nobody ever checked. +pub fn evaluate(limits: &QcLimits, rates: &RateSummary, drift: &Drift) -> QcStatus { + if limits.is_empty() { + return QcStatus::NotEvaluated; + } + let mut flags = Vec::new(); + + if let Some(limit) = limits.max_temperature_drift_c { + match drift.temperature_c { + Some(measured) if measured > limit => flags.push(format!( + "temperature drifted {measured:.2} °C, over the {limit:.2} °C limit" + )), + Some(_) => {} + None => flags.push( + "temperature drift could not be checked — the sensor reported no die temperature" + .into(), + ), + } + } + if let Some(limit) = limits.max_illumination_drift_percent { + match drift.illumination_percent { + Some(measured) if measured > limit => flags.push(format!( + "illumination drifted {measured:.1} %, over the {limit:.1} % limit" + )), + Some(_) => {} + None => flags.push( + "illumination drift could not be checked — the sensor reported no usable lux" + .into(), + ), + } + } + if let Some(limit) = limits.max_event_rate { + match rates.total_rate_hz() { + Some(measured) if measured > limit => flags.push(format!( + "event rate {measured:.0} ev/s, over the {limit:.0} ev/s limit" + )), + Some(_) => {} + None => flags.push( + "event rate could not be checked — no events were counted for this point".into(), + ), + } + } + + if flags.is_empty() { + QcStatus::Pass + } else { + QcStatus::Flagged(flags) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rates(on: u64, off: u64, seconds: f64) -> RateSummary { + RateSummary { + on_events: on, + off_events: off, + seconds, + } + } + + #[test] + fn rates_are_per_recorded_second_so_points_of_different_length_compare() { + let summary = rates(6_000, 4_000, 60.0); + assert_eq!(summary.total_events(), 10_000); + assert_eq!(summary.on_rate_hz(), Some(100.0)); + assert_eq!(summary.off_rate_hz(), Some(200.0 / 3.0)); + assert!((summary.total_rate_hz().expect("counted") - 166.666_666).abs() < 1e-4); + assert!((summary.on_fraction().expect("counted") - 0.6).abs() < 1e-12); + } + + #[test] + fn a_point_with_no_counted_interval_reports_no_rate_rather_than_zero() { + // Zero events per second is a measurement. "We never counted" is not, + // and the two must not be written into a sidecar as the same number. + let summary = rates(0, 0, 0.0); + assert_eq!(summary.total_rate_hz(), None); + assert_eq!(summary.on_fraction(), None); + // A real interval with genuinely no events *is* a rate of zero. + assert_eq!(rates(0, 0, 60.0).total_rate_hz(), Some(0.0)); + } + + #[test] + fn temperature_drift_is_absolute_and_illumination_drift_is_relative() { + let measured = drift( + Endpoints { + start: Some(41.0), + end: Some(43.5), + }, + Endpoints { + start: Some(200.0), + end: Some(190.0), + }, + ); + assert!((measured.temperature_c.expect("both ends") - 2.5).abs() < 1e-9); + assert!((measured.illumination_percent.expect("both ends") - 5.0).abs() < 1e-9); + } + + #[test] + fn a_channel_missing_either_end_reports_no_drift_rather_than_zero() { + let measured = drift( + Endpoints { + start: Some(41.0), + end: None, + }, + Endpoints::default(), + ); + assert_eq!(measured.temperature_c, None); + assert_eq!(measured.illumination_percent, None); + } + + #[test] + fn illumination_drift_against_a_dark_baseline_is_not_a_percentage() { + let measured = drift( + Endpoints::default(), + Endpoints { + start: Some(0.0), + end: Some(5.0), + }, + ); + assert_eq!(measured.illumination_percent, None); + } + + #[test] + fn a_row_with_no_limits_is_not_evaluated_rather_than_passing() { + let status = evaluate( + &QcLimits::default(), + &rates(100, 100, 60.0), + &Drift::default(), + ); + assert_eq!(status, QcStatus::NotEvaluated); + assert_eq!(status.as_str(), "not_evaluated"); + assert!(!status.is_flagged()); + } + + #[test] + fn a_point_inside_every_limit_passes() { + let limits = QcLimits { + max_temperature_drift_c: Some(3.0), + max_illumination_drift_percent: Some(10.0), + max_event_rate: Some(1_000.0), + }; + let status = evaluate( + &limits, + &rates(300, 300, 60.0), + &Drift { + temperature_c: Some(1.0), + illumination_percent: Some(2.0), + }, + ); + assert_eq!(status, QcStatus::Pass); + } + + #[test] + fn a_breach_names_the_measured_value_and_the_limit_it_passed() { + let limits = QcLimits { + max_temperature_drift_c: Some(1.0), + max_illumination_drift_percent: None, + max_event_rate: Some(100.0), + }; + let status = evaluate( + &limits, + &rates(6_000, 6_000, 60.0), + &Drift { + temperature_c: Some(2.5), + illumination_percent: None, + }, + ); + let flags = status.flags(); + assert_eq!(flags.len(), 2, "{flags:?}"); + assert!(flags[0].contains("2.50 °C"), "{flags:?}"); + assert!(flags[0].contains("1.00 °C"), "{flags:?}"); + assert!(flags[1].contains("200 ev/s"), "{flags:?}"); + assert!(status.is_flagged()); + } + + #[test] + fn a_limit_whose_quantity_was_never_measured_is_flagged_not_passed() { + // The failure this prevents: a camera with no temperature readback + // silently reporting every point as within a drift limit that was + // never actually checked. + let limits = QcLimits { + max_temperature_drift_c: Some(1.0), + ..QcLimits::default() + }; + let status = evaluate(&limits, &rates(10, 10, 60.0), &Drift::default()); + assert!(status.is_flagged()); + assert!( + status.flags()[0].contains("could not be checked"), + "{:?}", + status.flags() + ); + } + + #[test] + fn a_limit_exactly_met_is_not_a_breach() { + let limits = QcLimits { + max_temperature_drift_c: Some(2.0), + ..QcLimits::default() + }; + let status = evaluate( + &limits, + &rates(10, 10, 60.0), + &Drift { + temperature_c: Some(2.0), + illumination_percent: None, + }, + ); + assert_eq!(status, QcStatus::Pass); + } +} diff --git a/plugins/stage-a-a4/src/runtime.rs b/plugins/stage-a-a4/src/runtime.rs new file mode 100644 index 0000000..8a12563 --- /dev/null +++ b/plugins/stage-a-a4/src/runtime.rs @@ -0,0 +1,3304 @@ +//! Live A4 threshold-survey runner. +//! +//! A4 has one job. At a **fixed optical condition** it walks a protocol of +//! `(diff_on, diff_off)` bias pairs, and for each one it: +//! +//! 1. sets the two biases by cloning the camera configuration the host +//! confirmed when the run opened its session, and changing only `diff_on` +//! and `diff_off` — the host owns no A4-specific verb, so `fo`, `hpf`, +//! `refr`, the ROI and the pixel mask stay frozen because A4 copies them +//! forward unchanged (augur-rs ADR 037); +//! 2. **confirms against the sensor's own readback** that the absolute codes on +//! the die are `factory_default + offset`, and skips the point if they are +//! not, or if the reading is missing or older than the change; +//! 3. settles, and refuses to record until a monitoring sample newer than the +//! settle has arrived — a settle that produced no fresh telemetry is not a +//! settle; +//! 4. records a RAW file for the row's duration, counting ON/OFF events as it +//! goes; +//! 5. checks the receipt (size, hash, duration, clean finalization) and writes +//! an A4 sidecar carrying the protocol row, the bias codes, the bench +//! conditions and the QC verdict. +//! +//! Afterwards — on completion, on Stop, and on any abort — the biases the bench +//! was on before the survey are put back. +//! +//! A4 owns no hardware and never drives the Teensy. The optical condition is +//! the operator's: filters are changed by hand, and a protocol row that needs +//! one says `pause_before` and waits for a button. +//! +//! **What is a hard refusal and what is only a flag** is a deliberate split. +//! The sensor state that makes a threshold number mean something at all — the +//! event filters being off, the bias codes being confirmed, the file being +//! whole — is a gate. The bench *stability* limits (temperature drift, +//! illumination drift, event rate) are flags: the point is recorded, kept, and +//! marked, because whether a 2 °C drift invalidated it is a judgement to make +//! later with the file in hand, and a runner that discarded the point would +//! have thrown away the evidence for making it. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use augur_plugin_api::{ + export_plugin, CameraConfigurationSnapshotV1, CameraConfigurationSourceV1, EventFiltersV1, + EventStoreHandle, GlobalSettings, HostCommand, HostCommandOutcome, HostCommandReply, + HostCommandRequest, HostContext, HostDatasetDescriptor, HostDatasetKind, HostOutput, + HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, PathDialogKind, Plugin, + PluginCapabilities, PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, + PluginInput, PluginRuntimeRole, RoiV1, SensorBiasReadbackV1, SensorMonitoringV1, SettingItem, + SettingKind, SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, + TableColumnValues, TableDatasetV1, TableSchema, TableValueType, CTX_GLOBAL_SETTINGS, + CTX_SENSOR_MONITORING, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use stage_a_plugin_contract::telemetry; + +use crate::protocol::{self, A4Point, Protocol}; +use crate::qc::{self, Drift, Endpoints, QcStatus, RateSummary}; +use crate::sidecar; + +const STATUS_DATASET_ID: &str = "stage-a-a4.status"; +const STATUS_VIEW_ID: &str = "stage-a-a4.status.view"; +const POINTS_DATASET_ID: &str = "stage-a-a4.points"; +const POINTS_VIEW_ID: &str = "stage-a-a4.points.view"; + +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// How long to wait for any single host command to answer. The bias command +/// waits on a sensor read the host caps at five seconds, so this has to be +/// comfortably longer or a slow-but-working readback would look like a hang. +const REPLY_TIMEOUT_MS: u64 = 20_000; + +/// A confirming readback older than this is not evidence about the point being +/// recorded. The host already refuses to confirm with a reading taken before +/// the change; this is the plugin's own independent bound on how stale the +/// reading it *records as provenance* may be. +const MAX_READBACK_AGE_S: f64 = 2.0; + +/// A recording shorter than this fraction of what was asked for is a truncated +/// file, not a short one. Below it the point is not counted as recorded. +const MIN_DURATION_FRACTION: f64 = 0.9; + +/// Buttons cross the UI-mirror/live-worker boundary as a monotonic counter, not +/// as a bool. +/// +/// The host runs two instances of every plugin: a UI mirror that renders the +/// panel, and the live worker that actually runs the survey. A click arrives as +/// `true` on the clicked instance, while the other only ever sees the snapshot +/// value from `get_setting` — so a bool would either be missed or replayed +/// forever. A counter advance is one press edge, and the first counter a fresh +/// instance sees is adopted silently so a reloaded worker does not replay old +/// presses. +#[derive(Debug, Default, Clone, Copy)] +struct PressLatch { + counter: u64, + seen: Option, +} + +impl PressLatch { + fn accept(&mut self, value: &Value) -> bool { + if value.as_bool() == Some(true) { + self.counter += 1; + self.seen = Some(self.counter); + return true; + } + let Some(incoming) = value.as_u64() else { + return false; + }; + match self.seen { + None => { + self.seen = Some(incoming); + self.counter = self.counter.max(incoming); + false + } + Some(seen) if incoming > seen => { + self.seen = Some(incoming); + self.counter = self.counter.max(incoming); + true + } + Some(_) => false, + } + } + + fn value(&self) -> Value { + json!(self.counter) + } +} + +/// The two bias offsets A4 varies, around the sensor's per-unit factory trim. +/// +/// Plugin-local bookkeeping: the host contract carries all five biases, and A4 +/// deliberately reads and writes only these two. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct BiasOffsets { + diff_on: i32, + diff_off: i32, +} + +/// Where the run is in the current point's lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunPhase { + /// `ApplyCameraConfiguration { Current }` sent; waiting for the host to + /// confirm the configuration the survey will clone for every point. + OpeningSession, + /// Stopped before a point that needs a filter change or a dark cap. + PausedForOperator, + /// A point's configuration sent; waiting for the host's readback + /// confirmation. + ApplyingBiases, + /// Biases confirmed; waiting out `settle_s` and for fresh telemetry. + Settling, + /// `StartRecording` sent; waiting for the host to acknowledge. + StartingRecording, + /// RAW is being written and events are being counted. + Recording, + /// `StopRecording` sent; waiting for the finalize receipt. + StoppingRecording, + /// Every point is done; putting the operator's biases back. + RestoringBiases, +} + +/// How one point ended. +#[derive(Debug, Clone, PartialEq)] +enum PointOutcome { + Recorded, + /// Skipped or failed, with the reason in the operator's own terms. + Failed(String), +} + +/// One executed point, kept for the status table and the run receipt. +#[derive(Debug, Clone)] +struct PointRecord { + row: usize, + label: String, + diff_on: i64, + diff_off: i64, + repeat: (u32, u32), + outcome: PointOutcome, + codes: Option<(u8, u8)>, + raw: Option, + rates: RateSummary, + qc: QcStatus, +} + +impl PointRecord { + fn status_text(&self) -> String { + match &self.outcome { + PointOutcome::Recorded => "recorded".into(), + PointOutcome::Failed(reason) => format!("failed: {reason}"), + } + } +} + +/// State for the point currently in flight. +#[derive(Debug, Default)] +struct PointState { + stem: String, + /// Set once the host acknowledges the start. + raw_path: Option, + finalized_path: Option, + size: Option, + sha256: Option, + recorded_duration_s: Option, + complete: bool, + incomplete_reason: Option, + /// The confirmed readback for this point, and how stale it was. + readback: Option, + readback_age_s: f64, + applied: BiasOffsets, + /// Bench conditions at the two ends of the recording. + temperature: Endpoints, + illumination: Endpoints, + pixel_dead_time_us: Option, + sensor_age_s: Option, + rates: RateSummary, + /// Where event counting has consumed the stream up to. + counted_to_us: Option, + started_unix_ms: u64, + settle_until_ms: u64, + /// Latest sensor reading the plugin saw when the settle began; the settle + /// is not over until a *newer* one has arrived. + settle_started_ms: u64, + saw_fresh_sensor: bool, +} + +/// An in-flight survey. +#[derive(Debug)] +struct Run { + plan: Protocol, + protocol_path: String, + protocol_sha256: String, + measurement_id: String, + index: usize, + phase: RunPhase, + /// Request id currently awaited, and when it was sent. + pending_request: Option, + last_activity_ms: u64, + stop_requested: bool, + started_at_unix_ms: u64, + /// Set by a reply handler that has decided this point cannot be recorded. + /// Consumed by `drive`, which owns advancing the run — a reply arriving + /// mid-tick must not start the next point before this one is filed. + pending_skip: Option, + point: PointState, + records: Vec, + /// The offsets the bench was on before the survey started. + original: Option, + /// The configuration the host confirmed when this run opened its session. + /// Every point is this snapshot with two fields changed, which is what + /// keeps `fo`, `hpf`, `refr`, the ROI and the mask frozen across the sweep. + camera: Option, + biases_restored: bool, +} + +impl Run { + fn point(&self) -> Option<&A4Point> { + self.plan.points.get(self.index) + } +} + +pub struct StageAA4Plugin { + enabled: bool, + runtime_role: PluginRuntimeRole, + generation: u64, + + output_folder: String, + measurement_id: String, + protocol_path: String, + + press_start: PressLatch, + press_stop: PressLatch, + press_continue: PressLatch, + press_restore: PressLatch, + start_pending: bool, + stop_pending: bool, + continue_pending: bool, + restore_pending: bool, + + host_roi: Option, + sensor_size: (u16, u16), + masked_pixels: usize, + event_filters: Option, + sensor: Option, + /// Bumped every time a fresh monitoring sample lands, so the settle gate + /// can tell "a new reading arrived" from "the same one is still there". + sensor_seq: u64, + + run: Option, + request_seq: u64, + message: String, + /// The offsets the most recent survey found the bench on, kept after the + /// run ends so Restore still has something to put back. A run that died + /// with the host — a crash, a reload — leaves the sensor on whatever + /// threshold it was last set to, and this is the only record of where it + /// started. + last_original: Option, + /// A standalone restore, outside a run, and the request it is waiting on. + restore_request: Option, +} + +impl Default for StageAA4Plugin { + fn default() -> Self { + Self { + enabled: true, + runtime_role: PluginRuntimeRole::LiveWorker, + generation: 1, + output_folder: String::new(), + measurement_id: String::new(), + protocol_path: String::new(), + press_start: PressLatch::default(), + press_stop: PressLatch::default(), + press_continue: PressLatch::default(), + press_restore: PressLatch::default(), + start_pending: false, + stop_pending: false, + continue_pending: false, + restore_pending: false, + host_roi: None, + sensor_size: (1280, 720), + masked_pixels: 0, + event_filters: None, + sensor: None, + sensor_seq: 0, + run: None, + request_seq: 0, + message: "Pick an output folder and a protocol, then press Run protocol".into(), + last_original: None, + restore_request: None, + } + } +} + +/// The control surface the runner drives. Abstracted so the state machine can +/// be tested without a host. +trait HostControl { + fn request_host(&mut self, request: &HostCommandRequest); +} + +impl HostControl for PluginControlContext<'_> { + fn request_host(&mut self, request: &HostCommandRequest) { + // Fully qualified: the trait method and the inherent one share a name, + // so `self.request_host(..)` would resolve back to this one. + let _ = PluginControlContext::request_host(self, request); + } +} + +impl StageAA4Plugin { + fn bump(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + + fn note(&mut self, message: impl Into) { + self.message = message.into(); + self.bump(); + } + + fn next_request_id(&mut self) -> u64 { + self.request_seq += 1; + self.request_seq + } + + /// The offsets the bench is on right now, derived from the sensor's own + /// readback: the configured offset is `current - factory_default`. + /// + /// This is the only way to learn them. The panel value a plugin could read + /// belongs to the host settings UI, not to this plugin, and asking the + /// sensor is the same source the survey confirms every point against. + fn live_offsets(&self) -> Option { + let codes = self.sensor?.bias_codes?; + Some(BiasOffsets { + diff_on: codes.current.diff_on as i32 - codes.factory_default.diff_on as i32, + diff_off: codes.current.diff_off as i32 - codes.factory_default.diff_off as i32, + }) + } + + /// Why a survey must not start right now, phrased as the action that fixes + /// it. `None` means every gate is satisfied. + /// + /// Each of these is checked *before* the first bias moves, because the + /// whole point of a protocol is that it runs unattended: a file that cannot + /// work should say so on the button press. + fn start_blocker(&self) -> Option { + if self.run.is_some() { + return Some("A protocol is already running — press Stop to end it".into()); + } + if self.output_folder.trim().is_empty() { + return Some("Pick an output folder first — that is where the files go".into()); + } + if self.protocol_path.trim().is_empty() { + return Some("Choose a protocol file first".into()); + } + if let Some(filters) = self.event_filters { + let mut on = Vec::new(); + if filters.stc_enabled { + on.push("STC"); + } + if filters.trail_enabled { + on.push("Trail"); + } + if filters.erc_enabled { + on.push("ERC"); + } + if !on.is_empty() { + // These discard events before they are streamed, which is + // exactly the quantity a threshold survey counts. + return Some(format!( + "Turn {} off in the camera settings — a threshold survey counts events, and \ + {} drops some before they are streamed", + on.join(" and "), + if on.len() == 1 { "it" } else { "they" } + )); + } + } + // Without a readback the method is unverifiable: every point would + // record biases nobody can show were live. Refuse rather than run a + // survey whose central claim cannot be checked. + if self.sensor.and_then(|sensor| sensor.bias_codes).is_none() { + return Some( + "The sensor is not reporting its bias codes — A4 confirms every point against \ + that readback, so it will not run without one. Start Preview on a camera with \ + a monitoring block." + .into(), + ); + } + None + } + + /// Load, validate and start the protocol named in the settings. + fn begin_run(&mut self, context: &mut impl HostControl) { + if let Some(blocker) = self.start_blocker() { + self.note(blocker); + return; + } + let path = self.protocol_path.trim().to_owned(); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) => { + self.note(format!("Cannot read {path}: {error}")); + return; + } + }; + let plan = match protocol::parse_file(&path, &text) { + Ok(plan) => plan, + Err(error) => { + self.note(format!("Protocol rejected — {error}")); + return; + } + }; + + let measurement_id = self.ensure_measurement_id(); + let now_ms = now_unix_ms(); + let (on_axis, off_axis) = plan.axis_counts(); + let total = plan.points.len(); + let minutes = plan.total_seconds() / 60.0; + let pauses = if plan.has_pauses() { + " — it has operator pauses, so it cannot be left alone" + } else { + "" + }; + self.message = format!( + "Protocol '{}': {total} recordings ({on_axis} × diff_on, {off_axis} × diff_off), \ + about {minutes:.0} min of bench time{pauses}", + plan.name + ); + + // Captured from the sensor before anything moves, and kept after the + // run ends so Restore can still put the bench back. + let original = self.live_offsets(); + self.last_original = original.or(self.last_original); + + self.run = Some(Run { + plan, + protocol_sha256: sha256_hex(text.as_bytes()), + protocol_path: path, + measurement_id, + index: 0, + phase: RunPhase::OpeningSession, + pending_request: None, + last_activity_ms: now_ms, + stop_requested: false, + started_at_unix_ms: now_ms, + pending_skip: None, + point: PointState::default(), + records: Vec::new(), + original, + camera: None, + biases_restored: false, + }); + self.open_camera_session(context); + self.bump(); + } + + /// Ask the host to preserve and confirm the configuration the bench is on. + /// + /// This is the survey's baseline: the host keeps the pre-run state for the + /// closing restore, and the confirmed snapshot it answers with is what + /// every point clones. Nothing is recorded until it arrives, so a survey + /// can never sweep biases on top of a configuration nobody confirmed. + fn open_camera_session(&mut self, context: &mut impl HostControl) { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::OpeningSession; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } + + /// Begin the point at `index`: pause for the operator if the row asks, else + /// send its biases. + fn enter_point(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + let Some(point) = run.point().cloned() else { + self.finish_run(context); + return; + }; + let (index, total) = (run.index, run.plan.points.len()); + if let Some(run) = self.run.as_mut() { + run.point = PointState::default(); + run.last_activity_ms = now_unix_ms(); + } + if point.pause_before { + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::PausedForOperator; + } + self.note(format!( + "Paused before {}/{total} [{}]: set up '{}', then press Continue", + index + 1, + point.label, + if point.optical_state.is_empty() { + "the next optical condition" + } else { + point.optical_state.as_str() + } + )); + return; + } + self.send_biases(context); + } + + /// Ask the host to program this point's two biases. + /// + /// The request carries a complete configuration because that is the only + /// contract the host offers, but A4 builds it by cloning the snapshot the + /// session confirmed and changing exactly two fields. Everything the + /// threshold measurement depends on staying still is therefore carried + /// forward byte for byte from the baseline. + fn send_biases(&mut self, context: &mut impl HostControl) { + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let (index, total) = self + .run + .as_ref() + .map(|run| (run.index, run.plan.points.len())) + .unwrap_or((0, 0)); + let Some(mut snapshot) = self.run.as_ref().and_then(|run| run.camera.clone()) else { + self.skip_current( + "the host never confirmed a camera configuration for this survey, so there is \ + no baseline to change two biases against", + ); + return; + }; + snapshot.biases.diff_on = point.diff_on as i32; + snapshot.biases.diff_off = point.diff_off as i32; + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::ApplyingBiases; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + self.note(format!( + "Point {}/{total} [{}]: setting diff_on={}, diff_off={}…", + index + 1, + point.label, + point.diff_on, + point.diff_off + )); + } + + /// Start the RAW recording for the settled point. + fn start_recording(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + let Some(point) = run.point().cloned() else { + return; + }; + let (index, total) = (run.index, run.plan.points.len()); + let id = run.measurement_id.clone(); + let stem = format!( + "{id}_{}_{}", + format_compact_utc(now_unix_ms() / 1_000), + point.tag() + ); + let metadata = self.recording_metadata(&point, index, total); + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::StartRecording { + run_id: stem.clone(), + base_path: format!("{id}/{stem}.raw"), + metadata, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::StartingRecording; + run.pending_request = Some(request_id); + run.point.stem = stem; + run.last_activity_ms = now_unix_ms(); + } + self.note(format!( + "Point {}/{total} [{}]: recording for {} s…", + index + 1, + point.label, + point.duration_s + )); + } + + fn stop_recording(&mut self, context: &mut impl HostControl) { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::StopRecording, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::StoppingRecording; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } + + /// Metadata the host writes into the recording's own description, so a RAW + /// found on its own still says which point it is. + fn recording_metadata( + &self, + point: &A4Point, + index: usize, + total: usize, + ) -> BTreeMap { + let mut meta = BTreeMap::new(); + let run = self.run.as_ref(); + meta.insert( + "a4_measurement_id".into(), + run.map(|run| run.measurement_id.clone()) + .unwrap_or_default(), + ); + meta.insert("a4_label".into(), point.label.clone()); + meta.insert("a4_diff_on".into(), point.diff_on.to_string()); + meta.insert("a4_diff_off".into(), point.diff_off.to_string()); + meta.insert("a4_duration_s".into(), point.duration_s.to_string()); + meta.insert( + "a4_repeat".into(), + format!("{}/{}", point.repeat.0, point.repeat.1), + ); + if !point.optical_state.is_empty() { + meta.insert("a4_optical_state".into(), point.optical_state.clone()); + } + if !point.filter_id.is_empty() { + meta.insert("a4_filter_id".into(), point.filter_id.clone()); + } + if !point.flux_id.is_empty() { + meta.insert("a4_flux_id".into(), point.flux_id.clone()); + } + // Read by the host's crash breadcrumb, so a death during an unattended + // survey is pinned to the point it was on. + meta.insert("protocol_point_index".into(), (index + 1).to_string()); + meta.insert("protocol_point_total".into(), total.to_string()); + // The codes actually confirmed on the die for this point. + if let Some(readback) = run.and_then(|run| run.point.readback) { + meta.insert( + "a4_code_diff_on".into(), + readback.current.diff_on.to_string(), + ); + meta.insert( + "a4_code_diff_off".into(), + readback.current.diff_off.to_string(), + ); + } + // Bench conditions, each only when the sensor actually reported it — an + // absent reading must not arrive downstream as 0 °C or 0 lux. + if let Some(sensor) = self.sensor { + if let Some(celsius) = sensor.temperature_c { + meta.insert("sensor_temperature_c".into(), format!("{celsius:.2}")); + } + if let Some(lux) = sensor.illumination_lux { + meta.insert("sensor_illumination_lux".into(), format!("{lux:.3}")); + } + if let Some(dead_time) = sensor.pixel_dead_time_us { + meta.insert( + "sensor_pixel_dead_time_us".into(), + format!("{dead_time:.3}"), + ); + } + } + meta + } + + fn ensure_measurement_id(&mut self) -> String { + if self.measurement_id.trim().is_empty() { + self.measurement_id = generate_measurement_id(); + } + sanitize_stem(self.measurement_id.trim()) + } + + /// Give up on the current point and move on. The run continues: one bad + /// point out of forty is not a reason to lose the other thirty-nine. + fn fail_point(&mut self, context: &mut impl HostControl, reason: impl Into) { + let reason = reason.into(); + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let index = self.run.as_ref().map(|run| run.index).unwrap_or(0); + self.record_point(&point, index, PointOutcome::Failed(reason.clone())); + self.note(format!( + "Point {} [{}] skipped: {reason}", + index + 1, + point.label + )); + self.advance(context); + } + + /// File the point's outcome into the run's own record, and write its + /// sidecar. Both happen for failures too — the record of a failed point is + /// the reason the survey has a hole in it. + fn record_point(&mut self, point: &A4Point, index: usize, outcome: PointOutcome) { + let (rates, drift, status) = self.evaluate_point(point); + let codes = self + .run + .as_ref() + .and_then(|run| run.point.readback) + .map(|readback| (readback.current.diff_on, readback.current.diff_off)); + let raw = self.run.as_ref().and_then(|run| { + run.point + .finalized_path + .clone() + .or(run.point.raw_path.clone()) + }); + + // Gather before writing, so the sidecar records the final paths. + self.gather_point(); + if let Err(error) = self.write_sidecar(point, index, &outcome, &rates, &drift, &status) { + self.message = format!("{}; sidecar not saved: {error}", self.message); + } + + if let Some(run) = self.run.as_mut() { + run.records.push(PointRecord { + row: index + 1, + label: point.label.clone(), + diff_on: point.diff_on, + diff_off: point.diff_off, + repeat: point.repeat, + outcome, + codes, + raw, + rates, + qc: status, + }); + } + } + + fn evaluate_point(&self, point: &A4Point) -> (RateSummary, Drift, QcStatus) { + let Some(run) = self.run.as_ref() else { + return ( + RateSummary::default(), + Drift::default(), + QcStatus::NotEvaluated, + ); + }; + let rates = run.point.rates; + let drift = qc::drift(run.point.temperature, run.point.illumination); + let status = qc::evaluate(&point.limits, &rates, &drift); + (rates, drift, status) + } + + /// Step to the next point, or finish the run. + fn advance(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_mut() else { + return; + }; + run.index += 1; + run.last_activity_ms = now_unix_ms(); + let done = run.index >= run.plan.points.len() || run.stop_requested; + if done { + self.finish_run(context); + } else { + self.enter_point(context); + } + } + + /// Put the operator's biases back and end the run. + /// + /// The restore is a command like any other, so the run does not disappear + /// until it is answered — a survey that vanished while the sensor was still + /// on its last threshold would leave the bench silently misconfigured. + fn finish_run(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + // The host preserved the pre-run configuration when the session opened, + // so the restore is its own verb rather than a bias change back — which + // also puts back anything a point's snapshot carried along with the two + // biases. Nothing to restore if the session never opened. + if run.camera.is_some() && !run.biases_restored { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::RestoreCameraConfiguration, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::RestoringBiases; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } else { + self.close_run(); + } + } + + /// Write the run receipt, report, and drop the run. + fn close_run(&mut self) { + let Some(run) = self.run.take() else { + return; + }; + let recorded = run + .records + .iter() + .filter(|record| record.outcome == PointOutcome::Recorded) + .count(); + let failed = run.records.len() - recorded; + let flagged = run + .records + .iter() + .filter(|record| record.qc.is_flagged()) + .count(); + let total = run.plan.points.len(); + let name = run.plan.name.clone(); + let stopped = run.stop_requested; + + let receipt = self.write_receipt(&run, recorded, failed, flagged); + + let mut message = format!( + "Protocol '{name}' {}: {recorded}/{total} recorded", + if stopped { "stopped" } else { "finished" } + ); + if failed > 0 { + // Name the reasons, not just the count: an unattended run's whole + // report is this one line. + let mut reasons: Vec = run + .records + .iter() + .filter_map(|record| match &record.outcome { + PointOutcome::Failed(reason) => Some(reason.clone()), + PointOutcome::Recorded => None, + }) + .collect::>() + .into_iter() + .collect(); + reasons.truncate(3); + message.push_str(&format!(" — {failed} skipped ({})", reasons.join("; "))); + } + if flagged > 0 { + message.push_str(&format!(", {flagged} QC-flagged")); + } + message.push_str(if run.biases_restored { + ". Biases restored." + } else { + ". Biases NOT restored — check the camera settings." + }); + if let Err(error) = receipt { + message.push_str(&format!(" Protocol receipt not saved: {error}")); + } + self.note(message); + } + + // ---- artefacts --------------------------------------------------------- + + fn measurement_dir(&self) -> Option { + let run = self.run.as_ref()?; + let folder = self.output_folder.trim(); + if folder.is_empty() { + return None; + } + Some(Path::new(folder).join(&run.measurement_id)) + } + + /// Collect the finalized artefacts into `//`. + /// + /// The host resolves plugin recording paths below *its* output directory + /// and rejects absolute ones, so without this a measurement is split across + /// two unrelated folders. The RAW is closed and hashed by the time its + /// receipt arrives, so moving it here is safe. + fn gather_point(&mut self) { + let Some(dir) = self.measurement_dir() else { + return; + }; + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let raw = self.run.as_ref().and_then(|run| { + run.point + .finalized_path + .clone() + .or(run.point.raw_path.clone()) + }); + let Some(raw) = raw else { + return; + }; + if let Some(moved) = move_into(&dir, &raw) { + if let Some(run) = self.run.as_mut() { + if run.point.finalized_path.is_some() { + run.point.finalized_path = Some(moved.clone()); + } + run.point.raw_path = Some(moved); + } + } + // The host writes the camera's own bias/config sidecar as a sibling of + // the RAW; it travels with it so the recording stays self-describing. + if let Some(bias) = sibling_toml(&raw) { + move_into(&dir, &bias); + } + self.gather_sensor_readout(&dir, &raw); + } + + /// Compact the host's sensor-telemetry CSV into the measurement folder + /// under this recording's own stem, and remove the wide original. + /// + /// Best-effort throughout: a missing telemetry file is normal (a camera + /// with no monitoring block, a host that did not poll) and must not cost + /// the operator the point that just finished. + fn gather_sensor_readout(&mut self, dir: &Path, raw: &str) { + let Some(run) = self.run.as_ref() else { + return; + }; + let (id, stem) = (run.measurement_id.clone(), run.point.stem.clone()); + let source = Path::new(raw) + .file_stem() + .map(|file_stem| { + Path::new(raw) + .parent() + .unwrap_or(Path::new(".")) + .join(format!( + "{}.sensor-monitoring.csv", + file_stem.to_string_lossy() + )) + }) + .filter(|path| path.exists()); + let Some(source) = source else { + return; + }; + let Ok(text) = std::fs::read_to_string(&source) else { + return; + }; + let readout = telemetry::parse_csv(&text); + if readout.is_empty() { + // Nothing worth keeping, but the wide original is still clutter in + // the host's capture folder. + let _ = std::fs::remove_file(&source); + return; + } + let destination = dir.join(format!("{stem}.sensor.json")); + let json = readout.to_json(telemetry::SCHEMA_A4, &id, &stem); + if std::fs::write(&destination, json).is_ok() { + let _ = std::fs::remove_file(&source); + } + } + + fn write_sidecar( + &self, + point: &A4Point, + index: usize, + outcome: &PointOutcome, + rates: &RateSummary, + drift: &Drift, + status: &QcStatus, + ) -> Result { + let run = self.run.as_ref().ok_or("no run")?; + let dir = self.measurement_dir().ok_or("no output folder")?; + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let state = &run.point; + let readback = state.readback.unwrap_or_default(); + let roi = self.host_roi.unwrap_or_default(); + let filters = self.event_filters.unwrap_or_default(); + // A stem is only assigned once a recording starts; a point that failed + // before that still gets a sidecar, named after its protocol row. + let stem = if state.stem.is_empty() { + format!("{}_{}_unrecorded", run.measurement_id, point.tag()) + } else { + state.stem.clone() + }; + + let doc = sidecar::SidecarDoc { + schema: sidecar::SIDECAR_SCHEMA, + measurement_id: run.measurement_id.clone(), + recording: stem.clone(), + recorded_at_utc: format_iso_utc(now_unix_ms() / 1_000), + plugin_version: PLUGIN_VERSION, + protocol: sidecar::ProtocolSection { + name: run.plan.name.clone(), + file: run.protocol_path.clone(), + sha256: run.protocol_sha256.clone(), + row: index + 1, + rows_total: run.plan.points.len(), + label: point.label.clone(), + repeat: point.repeat.0, + repeats: point.repeat.1, + requested_duration_s: point.duration_s, + requested_settle_s: point.settle_s, + }, + bias: sidecar::BiasSection { + requested_diff_on: point.diff_on, + requested_diff_off: point.diff_off, + applied_diff_on: state.applied.diff_on, + applied_diff_off: state.applied.diff_off, + code_diff_on: readback.current.diff_on, + code_diff_off: readback.current.diff_off, + factory_diff_on: readback.factory_default.diff_on, + factory_diff_off: readback.factory_default.diff_off, + code_fo: readback.current.fo, + code_hpf: readback.current.hpf, + code_refr: readback.current.refr, + readback_age_s: state.readback_age_s, + confirmed: state.readback.is_some(), + }, + optics: sidecar::OpticsSection { + optical_state: point.optical_state.clone(), + filter_id: point.filter_id.clone(), + flux_id: point.flux_id.clone(), + paused_for_operator: point.pause_before, + }, + sensor: sidecar::SensorSection { + temperature_c_start: state.temperature.start, + temperature_c_end: state.temperature.end, + illumination_lux_start: state.illumination.start, + illumination_lux_end: state.illumination.end, + pixel_dead_time_us: state.pixel_dead_time_us, + reading_age_s: state.sensor_age_s, + illumination_note: + "Sensor lux is the die's own integrated reading, used here as a stability \ + indicator only. It is not a calibrated optical power.", + }, + filters: sidecar::FiltersSection { + stc_enabled: filters.stc_enabled, + trail_enabled: filters.trail_enabled, + erc_enabled: filters.erc_enabled, + erc_note: "This host has no event-rate controller, so ERC is off by construction \ + rather than by configuration.", + }, + camera: sidecar::CameraSection { + roi_x: roi.x, + roi_y: roi.y, + roi_width: roi.width, + roi_height: roi.height, + masked_pixels: self.masked_pixels, + sensor_width: self.sensor_size.0, + sensor_height: self.sensor_size.1, + }, + files: sidecar::FilesSection { + raw: state.finalized_path.clone().or(state.raw_path.clone()), + raw_size_bytes: state.size, + raw_sha256: state.sha256.clone(), + recorded_duration_s: state.recorded_duration_s, + sensor_readout: (!state.stem.is_empty()) + .then(|| dir.join(format!("{stem}.sensor.json"))) + .filter(|path| path.exists()) + .map(|path| path.display().to_string()), + complete: *outcome == PointOutcome::Recorded, + incomplete_reason: match outcome { + PointOutcome::Recorded => None, + PointOutcome::Failed(reason) => Some(reason.clone()), + }, + }, + qc: sidecar::QcSection { + status: status.as_str().to_owned(), + flags: status.flags().to_vec(), + on_events: rates.on_events, + off_events: rates.off_events, + total_events: rates.total_events(), + counted_seconds: rates.seconds, + on_rate_hz: rates.on_rate_hz(), + off_rate_hz: rates.off_rate_hz(), + total_rate_hz: rates.total_rate_hz(), + on_fraction: rates.on_fraction(), + temperature_drift_c: drift.temperature_c, + illumination_drift_percent: drift.illumination_percent, + limit_temperature_drift_c: point.limits.max_temperature_drift_c, + limit_illumination_drift_percent: point.limits.max_illumination_drift_percent, + limit_event_rate: point.limits.max_event_rate, + rate_note: "Rates are counted from the frames this plugin observed, over \ + counted_seconds. Compare that against recorded_duration_s for the coverage.", + }, + }; + + let path = dir.join(format!("{stem}.a4.toml")); + let text = toml::to_string_pretty(&doc).map_err(|error| error.to_string())?; + std::fs::write(&path, text).map_err(|error| error.to_string())?; + Ok(path.display().to_string()) + } + + /// Copy the protocol into the measurement folder and write the receipt + /// beside it, so the folder says which rows ran without anyone having to + /// diff filenames against the source file. + fn write_receipt( + &self, + run: &Run, + recorded: usize, + failed: usize, + flagged: usize, + ) -> Result<(), String> { + let folder = self.output_folder.trim(); + if folder.is_empty() { + return Err("no output folder".into()); + } + let dir = Path::new(folder).join(&run.measurement_id); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + + // The copy travels with the data; the original stays where the + // operator keeps it. + let source = Path::new(&run.protocol_path); + if let Some(name) = source.file_name() { + let _ = std::fs::copy(source, dir.join(name)); + } + + let receipt = sidecar::ProtocolReceipt { + schema: sidecar::RECEIPT_SCHEMA, + measurement_id: run.measurement_id.clone(), + protocol_name: run.plan.name.clone(), + protocol_file: run.protocol_path.clone(), + protocol_sha256: run.protocol_sha256.clone(), + started_at_utc: format_iso_utc(run.started_at_unix_ms / 1_000), + finished_at_utc: format_iso_utc(now_unix_ms() / 1_000), + outcome: if run.stop_requested { + "stopped".into() + } else { + "finished".into() + }, + rows_total: run.plan.points.len(), + rows_recorded: recorded, + rows_failed: failed, + rows_flagged: flagged, + restored_diff_on: run.original.map(|original| original.diff_on), + restored_diff_off: run.original.map(|original| original.diff_off), + biases_restored: run.biases_restored, + row: run + .records + .iter() + .map(|record| sidecar::ReceiptRow { + row: record.row, + label: record.label.clone(), + diff_on: record.diff_on, + diff_off: record.diff_off, + repeat: record.repeat.0, + status: match &record.outcome { + PointOutcome::Recorded => "recorded".into(), + PointOutcome::Failed(_) => "failed".into(), + }, + reason: match &record.outcome { + PointOutcome::Recorded => None, + PointOutcome::Failed(reason) => Some(reason.clone()), + }, + raw: record.raw.clone(), + qc: record.qc.as_str().to_owned(), + }) + .collect(), + }; + let name = format!("{}.protocol-status.toml", run.measurement_id); + let text = toml::to_string_pretty(&receipt).map_err(|error| error.to_string())?; + std::fs::write(dir.join(name), text).map_err(|error| error.to_string()) + } + + // ---- replies ----------------------------------------------------------- + + fn on_host_reply(&mut self, reply: &HostCommandReply) { + // A standalone restore, pressed outside a run. + if self.restore_request == Some(reply.request_id) { + self.restore_request = None; + self.message = match &reply.outcome { + HostCommandOutcome::CameraConfigurationRestored { readback, .. } => format!( + "Biases restored — the sensor reports diff_on={}, diff_off={}", + readback.current.diff_on, readback.current.diff_off + ), + HostCommandOutcome::Rejected { code, message } => { + format!("Restore refused ({code}): {message}") + } + _ => "Restore answered with an unexpected receipt".into(), + }; + self.bump(); + return; + } + + let Some(run) = self.run.as_ref() else { + return; + }; + if run.pending_request != Some(reply.request_id) { + return; + } + let phase = run.phase; + if let Some(run) = self.run.as_mut() { + run.pending_request = None; + run.last_activity_ms = now_unix_ms(); + } + match phase { + RunPhase::OpeningSession => self.on_session_reply(&reply.outcome), + RunPhase::ApplyingBiases => self.on_biases_reply(&reply.outcome), + RunPhase::StartingRecording => self.on_start_reply(&reply.outcome), + RunPhase::StoppingRecording => self.on_stop_reply(&reply.outcome), + RunPhase::RestoringBiases => { + if let Some(run) = self.run.as_mut() { + run.biases_restored = matches!( + reply.outcome, + HostCommandOutcome::CameraConfigurationRestored { .. } + ); + } + self.close_run(); + } + _ => {} + } + } + + /// The host confirmed the baseline configuration. Keep it as the snapshot + /// every point clones, then begin the first point. + /// + /// A survey that cannot get this cannot run at all — unlike a single point, + /// there is nothing to skip forward to — so a refusal ends the run instead + /// of failing a point. + fn on_session_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + // Kept here and consumed by `drive`, which owns advancing the run. + HostCommandOutcome::CameraConfigurationApplied { snapshot, .. } => { + if let Some(run) = self.run.as_mut() { + run.camera = Some(snapshot.clone()); + } + } + HostCommandOutcome::Rejected { code, message } => { + self.note(format!( + "The host refused to confirm the camera configuration ({code}): {message}" + )); + self.close_run(); + } + _ => { + self.note("The host answered the configuration request with an unexpected receipt"); + self.close_run(); + } + } + } + + fn on_biases_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + readback, + readback_age_s, + .. + } => { + let applied = BiasOffsets { + diff_on: snapshot.biases.diff_on, + diff_off: snapshot.biases.diff_off, + }; + let point = self.run.as_ref().and_then(|run| run.point().cloned()); + let Some(point) = point else { return }; + // The host already confirmed the codes; A4 checks them again + // against what *it* asked for. The two are the same check from + // two sides, and a threshold point is worth the second look. + let expected_on = expected_code(readback.factory_default.diff_on, point.diff_on); + let expected_off = expected_code(readback.factory_default.diff_off, point.diff_off); + if readback.current.diff_on != expected_on + || readback.current.diff_off != expected_off + { + let reason = format!( + "the sensor reports diff_on={}/diff_off={} but the row asks for \ + {expected_on}/{expected_off}", + readback.current.diff_on, readback.current.diff_off + ); + self.skip_current(reason); + return; + } + if *readback_age_s > MAX_READBACK_AGE_S { + self.skip_current(format!( + "the confirming bias reading was {readback_age_s:.1} s old, past the \ + {MAX_READBACK_AGE_S:.0} s this point will accept" + )); + return; + } + let now_ms = now_unix_ms(); + let settle_ms = (point.settle_s * 1_000.0).round().max(0.0) as u64; + let seq = self.sensor_seq; + if let Some(run) = self.run.as_mut() { + run.point.readback = Some(*readback); + run.point.readback_age_s = *readback_age_s; + run.point.applied = applied; + run.phase = RunPhase::Settling; + run.point.settle_until_ms = now_ms.saturating_add(settle_ms); + run.point.settle_started_ms = seq; + run.point.saw_fresh_sensor = false; + run.last_activity_ms = now_ms; + } + self.note(format!( + "Point [{}]: codes {}/{} confirmed, settling {:.1} s…", + point.label, + readback.current.diff_on, + readback.current.diff_off, + point.settle_s + )); + } + HostCommandOutcome::Rejected { code, message } => { + // Carry the host's own wording through: "turn the STC filter + // off" tells the operator what to do, "bias change failed" + // does not. + self.skip_current(format!( + "the host refused the bias change ({code}): {message}" + )); + } + _ => self.skip_current("the host answered the bias change with a recording receipt"), + } + } + + fn on_start_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + HostCommandOutcome::RecordingStarted { + actual_raw_path, .. + } => { + let now_ms = now_unix_ms(); + let sensor = self.sensor; + if let Some(run) = self.run.as_mut() { + run.point.raw_path = Some(actual_raw_path.clone()); + run.phase = RunPhase::Recording; + run.point.started_unix_ms = now_ms; + // Freeze the bench conditions this point begins under, + // before the recording has had time to move them. + if let Some(sensor) = sensor { + run.point.temperature.start = sensor.temperature_c; + run.point.illumination.start = sensor.illumination_lux; + run.point.pixel_dead_time_us = sensor.pixel_dead_time_us; + run.point.sensor_age_s = Some(sensor.age_s); + } + run.last_activity_ms = now_ms; + } + } + HostCommandOutcome::Rejected { code, message } => { + self.skip_current(format!( + "the host refused the recording ({code}): {message}" + )); + } + _ => self.skip_current("the host answered the start with an unexpected receipt"), + } + } + + fn on_stop_reply(&mut self, outcome: &HostCommandOutcome) { + let requested_s = self + .run + .as_ref() + .and_then(|run| run.point().map(|point| point.duration_s)) + .unwrap_or(0) as f64; + let sensor = self.sensor; + let Some(run) = self.run.as_mut() else { + return; + }; + if let Some(sensor) = sensor { + run.point.temperature.end = sensor.temperature_c; + run.point.illumination.end = sensor.illumination_lux; + } + let verdict = match outcome { + HostCommandOutcome::RecordingFinalized { + actual_raw_path, + size, + sha256, + duration_us, + } => { + let seconds = *duration_us as f64 / 1_000_000.0; + run.point.finalized_path = Some(actual_raw_path.clone()); + run.point.size = Some(*size); + run.point.sha256 = Some(sha256.clone()); + run.point.recorded_duration_s = Some(seconds); + // Every part of the receipt is checked, not just the word the + // host used: an empty file, a missing hash, or a recording cut + // short is not a threshold point. + if *size == 0 { + Err("the recording is empty (0 bytes)".to_owned()) + } else if sha256.trim().is_empty() { + Err("the recording finished without a hash".to_owned()) + } else if requested_s > 0.0 && seconds < requested_s * MIN_DURATION_FRACTION { + Err(format!( + "the recording is {seconds:.1} s of the {requested_s:.0} s asked for" + )) + } else { + Ok(()) + } + } + HostCommandOutcome::RecordingPartial { + actual_raw_path, + size, + sha256, + duration_us, + reason, + } => { + // Kept on disk and fully described, but never counted as a + // success: a partial file is not a threshold point. + run.point.finalized_path = Some(actual_raw_path.clone()); + run.point.size = *size; + run.point.sha256 = sha256.clone(); + run.point.recorded_duration_s = Some(*duration_us as f64 / 1_000_000.0); + Err(format!("the recording did not finalize cleanly: {reason}")) + } + HostCommandOutcome::Rejected { code, message } => { + Err(format!("the stop was refused ({code}): {message}")) + } + HostCommandOutcome::RecordingStarted { .. } + | HostCommandOutcome::CameraConfigurationApplied { .. } + | HostCommandOutcome::CameraConfigurationRestored { .. } => { + Err("the stop answered with an unexpected receipt".to_owned()) + } + }; + // Filed on the next tick by `drive`, which owns advancing the run. + run.point.complete = verdict.is_ok(); + run.point.incomplete_reason = verdict.err(); + } + + /// Mark the current point as unrecordable. `drive` files it on the next + /// tick — reply handlers must not advance the run themselves, or a reply + /// arriving mid-tick would start the next point before this one is filed. + fn skip_current(&mut self, reason: impl Into) { + let reason = reason.into(); + if let Some(run) = self.run.as_mut() { + run.pending_skip = Some(reason.clone()); + run.point.complete = false; + } + self.message = reason; + self.bump(); + } + + // ---- the tick ---------------------------------------------------------- + + /// Advance the run one control tick. + fn drive(&mut self, context: &mut impl HostControl) { + if self.restore_pending { + self.restore_pending = false; + self.restore_biases_now(context); + } + if self.run.is_none() { + if self.start_pending { + self.start_pending = false; + self.begin_run(context); + } + self.stop_pending = false; + self.continue_pending = false; + return; + } + if self.start_pending { + // Say so rather than swallowing the press: Stop is a different + // button, and a silently ignored one reads as a dead control. + self.start_pending = false; + self.note("A protocol is already running — press Stop to end it"); + } + if self.stop_pending { + self.stop_pending = false; + if let Some(run) = self.run.as_mut() { + run.stop_requested = true; + } + self.note("Stopping after the point in flight…"); + } + + let now_ms = now_unix_ms(); + // A reply handler decided this point cannot be recorded. File it here, + // before anything else looks at the phase. + if let Some(reason) = self.run.as_mut().and_then(|run| run.pending_skip.take()) { + self.fail_point(context, reason); + return; + } + + let (phase, stop_requested, pending, last_activity) = { + let run = self.run.as_ref().expect("checked above"); + ( + run.phase, + run.stop_requested, + run.pending_request, + run.last_activity_ms, + ) + }; + + // A command that never came back must not strand an unattended survey. + if pending.is_some() && now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + if let Some(run) = self.run.as_mut() { + run.pending_request = None; + } + match phase { + RunPhase::RestoringBiases => { + self.note( + "The host did not answer the bias restore — check the camera settings", + ); + self.close_run(); + } + // Nothing has been changed or recorded yet, and there is no + // baseline to record against, so end the run rather than fail + // every point in it one timeout at a time. + RunPhase::OpeningSession => { + self.note( + "The host did not confirm the camera configuration in time — the survey \ + did not start", + ); + self.close_run(); + } + RunPhase::StoppingRecording => { + self.fail_point(context, "the host did not answer the stop in time"); + } + _ => self.fail_point(context, "the host did not answer in time"), + } + return; + } + // Stop ends the run as soon as it can do so safely, which is not the + // same as immediately. A recording in flight has to wind down — an + // abandoned one leaves a truncated RAW behind — and a start already + // sent has to be answered before it can be stopped at all, or the host + // is left recording with nobody to end it. Everywhere else there is no + // file at risk, so waiting out a bias reply would only make Stop feel + // dead for twenty seconds. + if stop_requested + && matches!( + phase, + RunPhase::OpeningSession + | RunPhase::PausedForOperator + | RunPhase::ApplyingBiases + | RunPhase::Settling + ) + { + self.finish_run(context); + return; + } + if pending.is_some() { + return; + } + + match phase { + // The baseline reply has landed (pending is clear); begin the + // first point against it. + RunPhase::OpeningSession => { + if self.run.as_ref().is_some_and(|run| run.camera.is_some()) { + self.enter_point(context); + } + } + RunPhase::PausedForOperator => { + if self.continue_pending { + self.continue_pending = false; + self.send_biases(context); + } + } + // Waiting on a reply that has not arrived and has not timed out. + RunPhase::ApplyingBiases | RunPhase::StartingRecording | RunPhase::RestoringBiases => {} + RunPhase::Settling => { + if now_ms < self.run.as_ref().map_or(0, |run| run.point.settle_until_ms) { + return; + } + // A settle that produced no fresh telemetry is not a settle: + // without a new reading there is no evidence the bench has + // stopped moving, and the point's start conditions would be + // copied from before the bias change. + if !self + .run + .as_ref() + .is_some_and(|run| run.point.saw_fresh_sensor) + { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + self.fail_point( + context, + "no fresh sensor reading arrived during the settle, so the bench \ + could not be confirmed stable", + ); + } + return; + } + self.start_recording(context); + } + RunPhase::Recording => { + let (started, duration_s) = { + let run = self.run.as_ref().expect("checked above"); + ( + run.point.started_unix_ms, + run.point().map(|point| point.duration_s).unwrap_or(0), + ) + }; + let elapsed_ms = now_ms.saturating_sub(started); + let over = elapsed_ms >= (duration_s.max(0) as u64).saturating_mul(1_000); + if over || stop_requested { + self.stop_recording(context); + } + } + RunPhase::StoppingRecording => { + // The reply has landed (pending is clear); file the point. + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let index = self.run.as_ref().map(|run| run.index).unwrap_or(0); + let complete = self.run.as_ref().is_some_and(|run| run.point.complete); + let reason = self + .run + .as_ref() + .and_then(|run| run.point.incomplete_reason.clone()); + if complete { + self.record_point(&point, index, PointOutcome::Recorded); + self.note(format!("Point {} [{}] recorded", index + 1, point.label)); + self.advance(context); + } else { + self.fail_point( + context, + reason.unwrap_or_else(|| "the recording did not finalize".into()), + ); + } + } + } + } + + /// Put the biases back where the last survey found them, outside a run. + /// + /// This is the recovery path for a run that did not get to restore them + /// itself — a point left the sensor on a threshold nobody wants it on. + /// During a run it is refused: the run restores them when it ends, and a + /// restore in the middle would silently retarget the point being recorded. + /// + /// The pre-run state belongs to the host's session, not to this plugin, so + /// this asks the host to put it back rather than re-sending remembered + /// offsets. A host that was reloaded mid-survey has no session left and + /// refuses; its wording is carried through to the operator. + fn restore_biases_now(&mut self, context: &mut impl HostControl) { + if self.run.is_some() { + self.note("A protocol is running — it puts the biases back when it ends"); + return; + } + if self.restore_request.is_some() { + return; + } + if self.last_original.is_none() { + self.note( + "Nothing to restore — no survey has changed the biases since this plugin loaded", + ); + return; + } + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::RestoreCameraConfiguration, + }); + self.restore_request = Some(request_id); + self.note("Restoring the configuration the survey started from…"); + } + + // ---- datasets ---------------------------------------------------------- + + fn status_dataset(&self) -> TableDatasetV1 { + let column = |id: &str, value: String| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(vec![value]), + }; + let run = self.run.as_ref(); + let state = match run.map(|run| run.phase) { + None => "idle".to_owned(), + Some(RunPhase::OpeningSession) => "confirming the camera configuration".to_owned(), + Some(RunPhase::PausedForOperator) => "paused — press Continue".to_owned(), + Some(RunPhase::ApplyingBiases) => "setting biases".to_owned(), + Some(RunPhase::Settling) => "settling".to_owned(), + Some(RunPhase::StartingRecording) => "starting".to_owned(), + Some(RunPhase::Recording) => "recording".to_owned(), + Some(RunPhase::StoppingRecording) => "saving".to_owned(), + Some(RunPhase::RestoringBiases) => "restoring biases".to_owned(), + }; + let progress = run + .map(|run| { + format!( + "{}/{}", + (run.index + 1).min(run.plan.points.len()), + run.plan.points.len() + ) + }) + .unwrap_or_else(|| "—".into()); + let biases = run + .and_then(|run| run.point()) + .map(|point| format!("{} / {}", point.diff_on, point.diff_off)) + .unwrap_or_else(|| "—".into()); + let codes = self + .sensor + .and_then(|sensor| sensor.bias_codes) + .map(|codes| format!("{} / {}", codes.current.diff_on, codes.current.diff_off)) + .unwrap_or_else(|| "—".into()); + let rate = run + .and_then(|run| run.point.rates.total_rate_hz()) + .map(|hz| format!("{hz:.0} ev/s")) + .unwrap_or_else(|| "—".into()); + let temperature = self + .sensor + .and_then(|sensor| sensor.temperature_c) + .map(|celsius| format!("{celsius:.1} °C")) + .unwrap_or_else(|| "—".into()); + + TableDatasetV1 { + columns: vec![ + column("state", state), + column("progress", progress), + column("biases", biases), + column("codes", codes), + column("rate", rate), + column("temperature", temperature), + column("message", self.message.clone()), + ], + } + } + + fn points_dataset(&self) -> TableDatasetV1 { + let records: &[PointRecord] = self + .run + .as_ref() + .map(|run| run.records.as_slice()) + .unwrap_or(&[]); + let column = |id: &str, values: Vec| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(values), + }; + let map = + |select: fn(&PointRecord) -> String| records.iter().map(select).collect::>(); + TableDatasetV1 { + columns: vec![ + column("row", map(|record| record.row.to_string())), + column("label", map(|record| record.label.clone())), + column( + "offsets", + map(|record| format!("{} / {}", record.diff_on, record.diff_off)), + ), + column( + "codes", + map(|record| { + record + .codes + .map(|(on, off)| format!("{on} / {off}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "repeat", + map(|record| format!("{}/{}", record.repeat.0, record.repeat.1)), + ), + column( + "on_rate", + map(|record| { + record + .rates + .on_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "off_rate", + map(|record| { + record + .rates + .off_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "total_rate", + map(|record| { + record + .rates + .total_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column("qc", map(|record| record.qc.as_str().to_owned())), + column("status", map(|record| record.status_text())), + ], + } + } +} + +/// The absolute code a sensor programs for an offset: the factory trim plus the +/// offset, saturated into the 8-bit register. Mirrors the host's own rule so +/// A4 can state what it expects before the readback arrives. +fn expected_code(factory_default: u8, offset: i64) -> u8 { + (factory_default as i64 + offset).clamp(0, 255) as u8 +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Moves `source` into `dir`, returning the new path when it now lives there. +/// +/// A rename covers the common case (one volume) at zero cost; a cross-volume +/// move falls back to copy-then-delete, and the copy is size-checked before the +/// original goes away so a failed move never loses measurement data. `None` +/// means the file stayed where it was — callers keep the original path. +fn move_into(dir: &Path, source: &str) -> Option { + let source = Path::new(source); + let name = source.file_name()?; + if source.parent() == Some(dir) { + return None; + } + if !source.is_file() { + return None; + } + let destination = dir.join(name); + if destination.exists() { + return None; + } + if std::fs::rename(source, &destination).is_ok() { + return Some(destination.display().to_string()); + } + let copied = std::fs::copy(source, &destination).ok()?; + let expected = source.metadata().ok()?.len(); + if copied != expected { + let _ = std::fs::remove_file(&destination); + return None; + } + // Keeping the original after a verified copy is harmless; losing it is not. + let _ = std::fs::remove_file(source); + Some(destination.display().to_string()) +} + +fn sibling_toml(raw_path: &str) -> Option { + let path = Path::new(raw_path); + let stem = path.file_stem()?.to_string_lossy(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + Some(parent.join(format!("{stem}.toml")).display().to_string()) +} + +/// Replace anything that is not `[A-Za-z0-9._-]` with `_` so ids are file-safe. +fn sanitize_stem(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for character in input.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + out.push(character); + } else if !out.ends_with('_') { + out.push('_'); + } + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "A4".into() + } else { + trimmed + } +} + +fn generate_measurement_id() -> String { + let ms = now_unix_ms(); + format!("A4-{}-{:04x}", format_compact_date(ms / 1_000), ms & 0xffff) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// Gregorian date for a count of days since the Unix epoch (Howard Hinnant's +/// civil-from-days algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (year + i64::from(month <= 2), month, day) +} + +fn ymd_hms(unix_secs: u64) -> (i64, u32, u32, u64, u64, u64) { + let days = (unix_secs / 86_400) as i64; + let sod = unix_secs % 86_400; + let (year, month, day) = civil_from_days(days); + (year, month, day, sod / 3_600, (sod % 3_600) / 60, sod % 60) +} + +fn format_compact_date(unix_secs: u64) -> String { + let (y, m, d, ..) = ymd_hms(unix_secs); + format!("{y:04}{m:02}{d:02}") +} + +fn format_compact_utc(unix_secs: u64) -> String { + let (y, m, d, hh, mm, ss) = ymd_hms(unix_secs); + format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}") +} + +fn format_iso_utc(unix_secs: u64) -> String { + let (y, m, d, hh, mm, ss) = ymd_hms(unix_secs); + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +impl Plugin for StageAA4Plugin { + fn name(&self) -> &'static str { + "Stage-A A4 Threshold" + } + + fn description(&self) -> &'static str { + "Stage-A A4 contrast-threshold survey: steps diff_on/diff_off through a protocol at one \ + fixed optical condition, confirming every point against the sensor's own bias readback \ + before it records." + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn set_runtime_role(&mut self, role: PluginRuntimeRole) { + self.runtime_role = role; + } + + fn reset(&mut self) { + self.bump(); + } + + fn on_discontinuity(&mut self, reason: PluginDiscontinuity) { + // Starting and stopping the host recorder restarts the capture + // pipeline, and the host reports that as SourceChanged — twice per + // point. Those boundaries are self-inflicted, so none of them may + // disturb a survey in flight. Nothing here caches across a point + // anyway: the event counters are reset when each point starts. + let _ = reason; + } + + fn input_kind(&self) -> PluginInput { + PluginInput::RawEvents + } + + fn capabilities(&self) -> PluginCapabilities { + // The QC rates are counted from preview frames, which is enough for a + // stability indicator; the authoritative counts come from the RAW file + // offline. Retaining event history would cost memory a 60 s point does + // not need. + PluginCapabilities::default() + } + + fn process_frame( + &mut self, + frame: &PluginFrame<'_>, + _output: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _event_store: &EventStoreHandle<'_>, + ) { + if let Some(settings) = context + .get::(CTX_GLOBAL_SETTINGS) + .ok() + .flatten() + { + self.host_roi = Some(settings.roi); + self.masked_pixels = settings.masked_pixels.len(); + self.sensor_size = (settings.sensor_width, settings.sensor_height); + self.event_filters = Some(settings.event_filters); + } + if let Some(monitoring) = context + .get::(CTX_SENSOR_MONITORING) + .ok() + .flatten() + { + // Only a genuinely new reading counts as one: the host republishes + // the same snapshot on every frame between polls, and the settle + // gate is asking whether the sensor has been read *again*. + if self.sensor.map(|previous| previous.age_s) != Some(monitoring.age_s) { + self.sensor_seq = self.sensor_seq.wrapping_add(1); + if let Some(run) = self.run.as_mut() { + if run.phase == RunPhase::Settling + && self.sensor_seq != run.point.settle_started_ms + { + run.point.saw_fresh_sensor = true; + } + } + } + self.sensor = Some(monitoring); + } + + // Count events only while a point's RAW is being written, and only over + // the slice of the stream not yet counted — preview windows overlap, so + // taking every frame whole would double-count the overlap. + let recording = self + .run + .as_ref() + .is_some_and(|run| run.phase == RunPhase::Recording); + if recording { + let window_end = frame.window_end_us(); + let (mut on, mut off, mut seconds) = (0_u64, 0_u64, 0.0_f64); + if let Some(run) = self.run.as_ref() { + let from = run.point.counted_to_us.unwrap_or(window_end); + if window_end > from { + for event in frame.events() { + let timestamp = event.t_us.max(0) as u64; + if timestamp >= from && timestamp < window_end { + if event.polarity != 0 { + on += 1; + } else { + off += 1; + } + } + } + seconds = (window_end - from) as f64 / 1_000_000.0; + } + } + if let Some(run) = self.run.as_mut() { + run.point.rates.on_events += on; + run.point.rates.off_events += off; + run.point.rates.seconds += seconds; + run.point.counted_to_us = Some(window_end); + } + } + self.bump(); + } + + fn process_control(&mut self, context: &mut PluginControlContext<'_>) { + let inbox: PluginControlInbox = context.inbox().clone(); + for reply in &inbox.host_replies { + self.on_host_reply(reply); + } + self.drive(context); + self.bump(); + } + + fn settings_schema(&self) -> SettingsSchema { + // Deliberately *not* gated on "is something running": `settings_schema` + // is rendered by the UI mirror, and the run lives on the live worker, + // which is the only instance the host calls `process_control` on. A + // mirror reading its own always-idle state would disable nothing and + // mislead the next reader into thinking it did. The authoritative + // interlocks stay worker-side, where `start_blocker` refuses with a + // message that names what is wrong. + SettingsSchema { + sections: vec![ + SettingsSection { + label: "Measurement".into(), + description: Some( + "Where the survey's files go. The output folder is the only thing A4 \ + needs from you before it can run — the measurement id is filled in if \ + you leave it blank." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "output_folder".into(), + label: "Output folder".into(), + tooltip: Some( + "Every recording, sidecar and the protocol copy land in \ + //." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::Directory, + default: self.output_folder.clone(), + }, + }, + SettingItem { + key: "measurement_id".into(), + label: "Measurement id".into(), + tooltip: Some( + "Names the folder and every file stem under it. Left blank, a \ + dated one is generated and written back here." + .into(), + ), + kind: SettingKind::Text { + default: self.measurement_id.clone(), + }, + }, + ], + }, + SettingsSection { + label: "Protocol".into(), + description: Some( + "The survey itself: a CSV with one row per recording, or a TOML of \ + blocks and ranges. Every row names its bias pair, how long to record \ + and how long to settle first.\n\n\ + A4 changes nothing but diff_on and diff_off. The optical condition, the \ + ROI, the pixel mask and the other three biases are yours, and are \ + recorded with every point exactly as it found them." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "protocol_path".into(), + label: "Protocol file".into(), + tooltip: Some( + "A .csv or .toml protocol. It is validated in full on Run, so a \ + bad file is refused before the first bias moves." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::OpenFile, + default: self.protocol_path.clone(), + }, + }, + SettingItem { + key: "run_protocol".into(), + label: "Run protocol".into(), + tooltip: Some( + "Validate the file, capture the biases the bench is on now, and \ + record every point. The originals are put back at the end, on \ + Stop, and on any abort." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "continue_run".into(), + label: "Continue".into(), + tooltip: Some( + "Resume a protocol paused for a filter change or a dark cap." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "stop_protocol".into(), + label: "Stop".into(), + tooltip: Some( + "End the run after the recording in flight winds down — \ + abandoning it mid-write would leave a truncated RAW behind." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "restore_biases".into(), + label: "Restore biases".into(), + tooltip: Some( + "Put diff_on and diff_off back where the last survey found them. \ + A run does this itself when it ends; this is the recovery path \ + for one that could not — a reload mid-survey, say." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + ], + }, + ], + } + } + + fn get_setting(&self, key: &str) -> Option { + match key { + "output_folder" => Some(json!(self.output_folder)), + "measurement_id" => Some(json!(self.measurement_id)), + "protocol_path" => Some(json!(self.protocol_path)), + "run_protocol" => Some(self.press_start.value()), + "continue_run" => Some(self.press_continue.value()), + "stop_protocol" => Some(self.press_stop.value()), + "restore_biases" => Some(self.press_restore.value()), + _ => None, + } + } + + fn set_setting(&mut self, key: &str, value: Value) -> Result<(), String> { + match key { + "output_folder" => { + self.output_folder = value + .as_str() + .ok_or("output_folder must be a string")? + .to_string(); + } + "measurement_id" => { + self.measurement_id = value + .as_str() + .ok_or("measurement_id must be a string")? + .to_string(); + } + "protocol_path" => { + self.protocol_path = value + .as_str() + .ok_or("protocol_path must be a string")? + .to_string(); + } + // Every button arm is effectful, so each one is edge-guarded: the + // host syncs settings to both plugin instances, and an unguarded + // arm would fire twice per click. + "run_protocol" => { + if self.press_start.accept(&value) { + self.start_pending = true; + } + } + "continue_run" => { + if self.press_continue.accept(&value) { + self.continue_pending = true; + } + } + "stop_protocol" => { + if self.press_stop.accept(&value) { + self.stop_pending = true; + } + } + "restore_biases" => { + if self.press_restore.accept(&value) { + self.restore_pending = true; + } + } + _ => return Err(format!("unknown setting '{key}'")), + } + self.bump(); + Ok(()) + } + + fn status_entries(&self) -> Vec { + let mut entries = Vec::new(); + match self.run.as_ref() { + Some(run) => { + entries.push(StatusEntry::LabeledValue { + label: "Protocol".into(), + value: format!( + "{} — point {}/{}", + run.plan.name, + (run.index + 1).min(run.plan.points.len()), + run.plan.points.len() + ), + color: None, + }); + let recorded = run + .records + .iter() + .filter(|record| record.outcome == PointOutcome::Recorded) + .count(); + let flagged = run + .records + .iter() + .filter(|record| record.qc.is_flagged()) + .count(); + entries.push(StatusEntry::Text(format!( + "{recorded} recorded, {} skipped, {flagged} QC-flagged", + run.records.len() - recorded + ))); + } + None => { + entries.push(StatusEntry::LabeledValue { + label: "Protocol".into(), + value: "idle".into(), + color: None, + }); + if let Some(blocker) = self.start_blocker() { + entries.push(StatusEntry::Text(format!("Not ready — {blocker}"))); + } + } + } + // The bias codes the sensor is actually running, always, so the panel + // never has to be trusted about them. + entries.push(StatusEntry::Text( + match self.sensor.and_then(|sensor| sensor.bias_codes) { + Some(codes) => format!( + "Sensor reports diff_on={} (offset {}), diff_off={} (offset {})", + codes.current.diff_on, + codes.current.diff_on as i32 - codes.factory_default.diff_on as i32, + codes.current.diff_off, + codes.current.diff_off as i32 - codes.factory_default.diff_off as i32, + ), + None => { + "The sensor is not reporting bias codes — A4 will not run without them".into() + } + }, + )); + entries.push(StatusEntry::Text(self.message.clone())); + entries + } + + fn host_views(&self) -> HostViewRegistry { + fn column(id: &str, title: &str) -> TableColumn { + TableColumn { + id: id.into(), + title: title.into(), + value_type: TableValueType::String, + } + } + HostViewRegistry { + datasets: vec![ + HostDatasetDescriptor { + id: STATUS_DATASET_ID.into(), + title: "A4 status".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("state", "State"), + column("progress", "Point"), + column("biases", "Asked (on/off)"), + column("codes", "On die (on/off)"), + column("rate", "Rate"), + column("temperature", "Die temp"), + column("message", "Message"), + ], + ..TableSchema::default() + }), + empty_message: "A4 idle".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: POINTS_DATASET_ID.into(), + title: "A4 threshold points".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("row", "Row"), + column("label", "Label"), + column("offsets", "Offsets"), + column("codes", "Codes"), + column("repeat", "Repeat"), + column("on_rate", "ON (ev/s)"), + column("off_rate", "OFF (ev/s)"), + column("total_rate", "Total (ev/s)"), + column("qc", "QC"), + column("status", "Status"), + ], + ..TableSchema::default() + }), + empty_message: "No points recorded yet — press Run protocol".into(), + display: None, + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: STATUS_VIEW_ID.into(), + title: "A4 status".into(), + dataset_id: STATUS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: POINTS_VIEW_ID.into(), + title: "A4 threshold points".into(), + dataset_id: POINTS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::TableWindow, + }, + ], + actions: Vec::new(), + } + } + + fn host_view_dataset(&self, dataset_id: &str) -> Option> { + match dataset_id { + STATUS_DATASET_ID => serde_json::to_vec(&self.status_dataset()).ok(), + POINTS_DATASET_ID => serde_json::to_vec(&self.points_dataset()).ok(), + _ => None, + } + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + match dataset_id { + STATUS_DATASET_ID | POINTS_DATASET_ID => self.generation, + _ => 0, + } + } +} + +export_plugin!(StageAA4Plugin); + +#[cfg(test)] +mod tests { + use super::*; + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraConfigurationProvenanceV1, CameraDigitalFilterV1, + CameraExternalTriggerV1, CameraGlobalSettingsV1, SensorBiasCodesV1, SensorBiasReadbackV1, + }; + + /// Factory trim of the unit these tests pretend to run on. + const FACTORY_ON: u8 = 102; + const FACTORY_OFF: u8 = 40; + + #[derive(Default)] + struct ControlSink { + hosts: Vec, + } + + impl HostControl for ControlSink { + fn request_host(&mut self, request: &HostCommandRequest) { + self.hosts.push(request.clone()); + } + } + + impl ControlSink { + fn last_id(&self) -> u64 { + self.hosts + .last() + .map(|request| request.request_id) + .unwrap_or(0) + } + + /// The two biases of every configuration a point applied. The session's + /// opening `Current` carries no snapshot and does not appear here. + fn applied_biases(&self) -> Vec<(i32, i32)> { + self.applied_snapshots() + .iter() + .map(|snapshot| (snapshot.biases.diff_on, snapshot.biases.diff_off)) + .collect() + } + + fn applied_snapshots(&self) -> Vec { + self.hosts + .iter() + .filter_map(|request| match &request.command { + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + } => Some(snapshot.clone()), + _ => None, + }) + .collect() + } + + fn restores(&self) -> usize { + self.hosts + .iter() + .filter(|request| { + matches!(request.command, HostCommand::RestoreCameraConfiguration) + }) + .count() + } + } + + const BASELINE_ON: i32 = 7; + const BASELINE_OFF: i32 = -3; + + /// The configuration the host confirms when a survey opens its session. + /// Everything except the two biases must survive the sweep untouched. + fn baseline_snapshot() -> CameraConfigurationSnapshotV1 { + CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1 { + diff_on: BASELINE_ON, + diff_off: BASELINE_OFF, + fo: 4, + hpf: 1, + refr: -2, + }, + roi: RoiV1 { + x: 16, + y: 32, + width: 640, + height: 480, + }, + masked_pixels: vec![(3, 4), (5, 6)], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 10_000, + trail_enabled: false, + erc_enabled: Some(false), + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 2, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 100.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 20, + event_store_budget_mib: 512, + preview_interval_ms: 33, + point_cloud_interval_ms: 100, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + } + } + + fn readback(on_offset: i64, off_offset: i64) -> SensorBiasReadbackV1 { + SensorBiasReadbackV1 { + current: SensorBiasCodesV1 { + diff_on: expected_code(FACTORY_ON, on_offset), + diff_off: expected_code(FACTORY_OFF, off_offset), + fo: 55, + hpf: 0, + refr: 138, + }, + factory_default: SensorBiasCodesV1 { + diff_on: FACTORY_ON, + diff_off: FACTORY_OFF, + fo: 55, + hpf: 0, + refr: 138, + }, + } + } + + fn monitoring(on_offset: i64, off_offset: i64, age_s: f64) -> SensorMonitoringV1 { + SensorMonitoringV1 { + pixel_dead_time_us: Some(12.5), + illumination_lux: Some(200.0), + temperature_c: Some(41.0), + bias_codes: Some(readback(on_offset, off_offset)), + age_s, + } + } + + fn temp_folder(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("a4-{tag}-{}", now_unix_ms())); + std::fs::create_dir_all(&dir).expect("test folder"); + dir + } + + /// A plugin that has seen a camera reporting its biases, so `start_blocker` + /// is satisfied and `live_offsets` has something to capture. + fn ready_plugin(folder: &Path, protocol: &Path) -> StageAA4Plugin { + StageAA4Plugin { + output_folder: folder.display().to_string(), + protocol_path: protocol.display().to_string(), + measurement_id: "A4-TEST".into(), + event_filters: Some(EventFiltersV1::default()), + sensor: Some(monitoring(7, -3, 0.1)), + ..StageAA4Plugin::default() + } + } + + fn write_protocol(folder: &Path, body: &str) -> PathBuf { + let path = folder.join("survey.csv"); + std::fs::write(&path, body).expect("protocol written"); + path + } + + /// Mark the settle as satisfied, the way a fresh monitoring frame would. + fn deliver_fresh_sensor(plugin: &mut StageAA4Plugin, sensor: SensorMonitoringV1) { + plugin.sensor_seq = plugin.sensor_seq.wrapping_add(1); + if let Some(run) = plugin.run.as_mut() { + if run.phase == RunPhase::Settling { + run.point.saw_fresh_sensor = true; + } + } + plugin.sensor = Some(sensor); + } + + fn applied_reply( + request_id: u64, + on_offset: i64, + off_offset: i64, + age_s: f64, + ) -> HostCommandReply { + let mut snapshot = baseline_snapshot(); + snapshot.biases.diff_on = on_offset as i32; + snapshot.biases.diff_off = off_offset as i32; + HostCommandReply { + request_id, + outcome: HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance: CameraConfigurationProvenanceV1 { + source: "snapshot".into(), + profile_name: None, + schema_version: 1, + profile_revision: None, + sha256: "0".repeat(64), + }, + readback: readback(on_offset, off_offset), + readback_age_s: age_s, + }, + } + } + + fn restored_reply(request_id: u64) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::CameraConfigurationRestored { + readback: readback(BASELINE_ON as i64, BASELINE_OFF as i64), + readback_age_s: 0.1, + }, + } + } + + fn started_reply(request_id: u64, path: &str) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::RecordingStarted { + actual_raw_path: path.to_owned(), + started_at: "2026-08-08T10:00:00Z".into(), + }, + } + } + + fn finalized_reply( + request_id: u64, + path: &str, + size: u64, + duration_us: u64, + ) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::RecordingFinalized { + actual_raw_path: path.to_owned(), + size, + sha256: "a".repeat(64), + duration_us, + }, + } + } + + fn rejected_reply(request_id: u64, code: &str, message: &str) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::Rejected { + code: code.into(), + message: message.into(), + }, + } + } + + /// Mirrors the ordering of `process_control`. + fn tick(plugin: &mut StageAA4Plugin, replies: Vec, sink: &mut ControlSink) { + for reply in &replies { + plugin.on_host_reply(reply); + } + plugin.drive(sink); + } + + /// Answer the run's closing restore, so it writes its receipt and ends. + /// The receipt records whether the biases went back, so it is deliberately + /// not written until that is known. + fn settle_restore(plugin: &mut StageAA4Plugin, sink: &mut ControlSink) { + let restore_id = sink.last_id(); + tick(plugin, vec![restored_reply(restore_id)], sink); + } + + /// Press Run and answer the baseline confirmation every survey opens with. + /// Leaves the run on its first point's configuration command — or paused, + /// if the first row asks the operator for something. + fn start_survey(plugin: &mut StageAA4Plugin, sink: &mut ControlSink) { + plugin.start_pending = true; + tick(plugin, vec![], sink); + let session_id = sink.last_id(); + tick( + plugin, + vec![applied_reply( + session_id, + BASELINE_ON as i64, + BASELINE_OFF as i64, + 0.1, + )], + sink, + ); + } + + /// Walk one point from its bias command through a clean finalize. Returns + /// the RAW path it was told to write. + fn run_one_point( + plugin: &mut StageAA4Plugin, + sink: &mut ControlSink, + folder: &Path, + on_offset: i64, + off_offset: i64, + ) -> PathBuf { + let bias_id = sink.last_id(); + tick( + plugin, + vec![applied_reply(bias_id, on_offset, off_offset, 0.2)], + sink, + ); + deliver_fresh_sensor(plugin, monitoring(on_offset, off_offset, 0.1)); + // Settle is 0 s in the test protocols, so the next tick starts it. + tick(plugin, vec![], sink); + + let start_id = sink.last_id(); + let raw = folder.join(format!("point-{on_offset}-{off_offset}.raw")); + std::fs::write(&raw, b"raw-bytes").expect("raw written"); + tick( + plugin, + vec![started_reply(start_id, &raw.display().to_string())], + sink, + ); + // Duration is 1 s in the test protocols; force the clock past it. + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(5_000); + } + tick(plugin, vec![], sink); + + let stop_id = sink.last_id(); + tick( + plugin, + vec![finalized_reply( + stop_id, + &raw.display().to_string(), + 9, + 1_000_000, + )], + sink, + ); + tick(plugin, vec![], sink); + raw + } + + #[test] + fn a_survey_sets_confirms_records_and_then_puts_the_biases_back() { + let folder = temp_folder("happy"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n-20,-10,1,0\n20,20,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + + tick(&mut plugin, vec![], &mut sink); + start_survey(&mut plugin, &mut sink); + + run_one_point(&mut plugin, &mut sink, &folder, -20, -10); + run_one_point(&mut plugin, &mut sink, &folder, 20, 20); + + assert_eq!( + sink.applied_biases(), + vec![(-20, -10), (20, 20)], + "each point applies its own two biases" + ); + // The host preserved the pre-run configuration when the session opened, + // so putting the bench back is its own verb, sent last. + assert_eq!(sink.restores(), 1); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration), + )); + + // The run only closes once the restore is answered. + assert!( + plugin.run.is_some(), + "the run waits for its restore receipt" + ); + let restore_id = sink.last_id(); + tick(&mut plugin, vec![restored_reply(restore_id)], &mut sink); + assert!(plugin.run.is_none(), "the run ends after the restore"); + assert!( + plugin.message.contains("2/2 recorded"), + "{}", + plugin.message + ); + assert!( + plugin.message.contains("Biases restored"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_recorded_point_lands_in_the_measurement_folder_with_its_sidecar() { + let folder = temp_folder("gather"); + let protocol = write_protocol( + &folder, + "label,diff_on,diff_off,duration_s,settle_s\nthr-01,12,-8,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 12, -8); + settle_restore(&mut plugin, &mut sink); + + let dir = folder.join("A4-TEST"); + let sidecars: Vec = std::fs::read_dir(&dir) + .expect("measurement folder") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.to_string_lossy().ends_with(".a4.toml")) + .collect(); + assert_eq!(sidecars.len(), 1, "one sidecar per point: {sidecars:?}"); + let text = std::fs::read_to_string(&sidecars[0]).expect("sidecar readable"); + + // The absolute codes, not just the offsets the row asked for — this is + // the whole reason the sidecar exists. + assert!( + text.contains(&format!("code_diff_on = {}", FACTORY_ON as i64 + 12)), + "{text}" + ); + assert!( + text.contains(&format!("code_diff_off = {}", FACTORY_OFF as i64 - 8)), + "{text}" + ); + assert!(text.contains("requested_diff_on = 12"), "{text}"); + assert!(text.contains("confirmed = true"), "{text}"); + assert!(text.contains("complete = true"), "{text}"); + assert!(text.contains("label = \"thr-01\""), "{text}"); + // The protocol travels with the data, with its hash. + assert!(dir.join("survey.csv").exists(), "the protocol is copied in"); + assert!(text.contains("sha256"), "{text}"); + // And the RAW was moved out of the host's folder into this one. + assert!( + dir.join("point-12--8.raw").exists(), + "the RAW is gathered in" + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn codes_that_disagree_with_the_row_skip_the_point_and_keep_going() { + // The central guarantee: a point whose biases cannot be shown to be + // the requested ones is not recorded at all. + let folder = temp_folder("mismatch"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n10,10,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + // The sensor answers with codes for a different offset entirely. + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 99, 99, 0.1)], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + // No recording was ever started for that point, and the run moved on. + assert!( + !sink + .hosts + .iter() + .any(|request| matches!(request.command, HostCommand::StartRecording { .. })), + "a mismatched point must not be recorded" + ); + let records = &plugin.run.as_ref().expect("still running").records; + assert_eq!(records.len(), 1); + assert!(matches!(records[0].outcome, PointOutcome::Failed(_))); + assert!( + records[0].status_text().contains("the row asks for"), + "{}", + records[0].status_text() + ); + // And the second point is under way rather than the run being over. + assert_eq!(plugin.run.as_ref().expect("still running").index, 1); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_stale_confirming_reading_is_not_evidence_about_this_point() { + let folder = temp_folder("stale"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + // Correct codes, but read far too long after the change. + tick( + &mut plugin, + vec![applied_reply(bias_id, 5, 5, 9.0)], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + let records = &plugin + .run + .as_ref() + .map(|run| run.records.clone()) + .unwrap_or_default(); + assert_eq!(records.len(), 1); + assert!( + records[0].status_text().contains("old"), + "{}", + records[0].status_text() + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_refused_bias_change_quotes_the_hosts_own_reason() { + // "Turn the STC filter off" tells the operator what to do; "bias + // change failed" does not. + let folder = temp_folder("refused"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![rejected_reply( + bias_id, + "event_filters_enabled", + "turn the STC and Trail filters off before changing threshold biases", + )], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + let message = plugin + .run + .as_ref() + .and_then(|run| run.records.first().map(|record| record.status_text())) + .unwrap_or_default(); + assert!(message.contains("STC"), "{message}"); + assert!(message.contains("event_filters_enabled"), "{message}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_partial_receipt_is_never_counted_as_a_recorded_point() { + let folder = temp_folder("partial"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + deliver_fresh_sensor(&mut plugin, monitoring(0, 0, 0.1)); + tick(&mut plugin, vec![], &mut sink); + let start_id = sink.last_id(); + let raw = folder.join("partial.raw"); + std::fs::write(&raw, b"x").expect("raw written"); + tick( + &mut plugin, + vec![started_reply(start_id, &raw.display().to_string())], + &mut sink, + ); + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(5_000); + } + tick(&mut plugin, vec![], &mut sink); + + let stop_id = sink.last_id(); + tick( + &mut plugin, + vec![HostCommandReply { + request_id: stop_id, + outcome: HostCommandOutcome::RecordingPartial { + actual_raw_path: raw.display().to_string(), + size: Some(1), + sha256: None, + duration_us: 1_000_000, + reason: "the writer did not flush".into(), + }, + }], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + settle_restore(&mut plugin, &mut sink); + + let receipt = std::fs::read_to_string(folder.join("A4-TEST/A4-TEST.protocol-status.toml")) + .expect("receipt written"); + assert!(receipt.contains("rows_recorded = 0"), "{receipt}"); + assert!(receipt.contains("rows_failed = 1"), "{receipt}"); + assert!(receipt.contains("did not finalize cleanly"), "{receipt}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_recording_cut_short_is_a_truncated_file_not_a_short_point() { + let folder = temp_folder("short"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,60,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + deliver_fresh_sensor(&mut plugin, monitoring(0, 0, 0.1)); + tick(&mut plugin, vec![], &mut sink); + let start_id = sink.last_id(); + let raw = folder.join("short.raw"); + std::fs::write(&raw, b"x").expect("raw written"); + tick( + &mut plugin, + vec![started_reply(start_id, &raw.display().to_string())], + &mut sink, + ); + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(70_000); + } + tick(&mut plugin, vec![], &mut sink); + + // A clean receipt, but only 10 s of the 60 s asked for. + let stop_id = sink.last_id(); + tick( + &mut plugin, + vec![finalized_reply( + stop_id, + &raw.display().to_string(), + 4096, + 10_000_000, + )], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + settle_restore(&mut plugin, &mut sink); + + let receipt = std::fs::read_to_string(folder.join("A4-TEST/A4-TEST.protocol-status.toml")) + .expect("receipt written"); + assert!(receipt.contains("rows_recorded = 0"), "{receipt}"); + assert!(receipt.contains("10.0 s of the 60 s"), "{receipt}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_settle_with_no_fresh_reading_never_starts_a_recording() { + // Without a new reading there is no evidence the bench stopped moving, + // and the point's start conditions would be copied from before the + // bias change. + let folder = temp_folder("nosettle"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + + // Several ticks with no new monitoring sample. + for _ in 0..3 { + tick(&mut plugin, vec![], &mut sink); + } + assert!( + !sink + .hosts + .iter() + .any(|request| matches!(request.command, HostCommand::StartRecording { .. })), + "no recording may start without a fresh reading" + ); + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::Settling) + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_row_that_needs_a_filter_change_waits_for_the_operator() { + let folder = temp_folder("pause"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s,pause_before,optical_state\n\ + 0,0,1,0,yes,LP647+BP700\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::PausedForOperator) + ); + assert!( + sink.applied_biases().is_empty(), + "nothing moves while paused" + ); + assert!(plugin.message.contains("LP647+BP700"), "{}", plugin.message); + + plugin.continue_pending = true; + tick(&mut plugin, vec![], &mut sink); + assert_eq!(sink.applied_biases(), vec![(0, 0)]); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_survey_refuses_to_start_while_a_filter_is_dropping_events() { + let folder = temp_folder("filters"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + plugin.event_filters = Some(EventFiltersV1 { + stc_enabled: true, + trail_enabled: false, + erc_enabled: false, + }); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none(), "the survey must not start"); + assert!(sink.hosts.is_empty(), "nothing is sent to the host"); + assert!(plugin.message.contains("STC"), "{}", plugin.message); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_survey_refuses_to_start_without_a_bias_readback_to_confirm_against() { + // Without one, every point would record biases nobody can show were + // live — the method's central claim would be uncheckable. + let folder = temp_folder("noreadback"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + plugin.sensor = Some(SensorMonitoringV1 { + bias_codes: None, + ..monitoring(0, 0, 0.1) + }); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none()); + assert!(plugin.message.contains("bias codes"), "{}", plugin.message); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn an_invalid_protocol_is_refused_before_a_single_bias_moves() { + let folder = temp_folder("badfile"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,900\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none()); + assert!(sink.hosts.is_empty(), "nothing reached the host"); + assert!( + plugin.message.contains("Protocol rejected"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn stop_ends_the_run_and_still_restores_the_biases() { + let folder = temp_folder("stop"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n15,15,1,0\n25,25,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 5, 5); + + plugin.stop_pending = true; + tick(&mut plugin, vec![], &mut sink); + tick(&mut plugin, vec![], &mut sink); + + // Point 2 had already been targeted when Stop arrived — it is dropped + // before it records — and point 3 was never reached at all. + assert_eq!( + sink.applied_biases(), + vec![(5, 5), (15, 15)], + "Stop must not target another point" + ); + assert!( + matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration), + ), + "a stopped run still puts the bench back" + ); + settle_restore(&mut plugin, &mut sink); + assert!(plugin.message.contains("stopped"), "{}", plugin.message); + assert!( + plugin.message.contains("1/3 recorded"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_host_that_never_answers_does_not_strand_an_unattended_survey() { + let folder = temp_folder("timeout"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + // Push the send far enough into the past to trip the reply timeout. + if let Some(run) = plugin.run.as_mut() { + run.last_activity_ms = now_unix_ms().saturating_sub(REPLY_TIMEOUT_MS + 1_000); + } + tick(&mut plugin, vec![], &mut sink); + + assert!( + plugin.message.contains("did not answer") || plugin.message.contains("skipped"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_reply_to_a_request_the_run_is_not_waiting_on_is_ignored() { + // The runtime caches and can re-emit replies; a stale one must not + // advance a point that is waiting on a different request. + let folder = temp_folder("stalereply"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let waiting_on = plugin.run.as_ref().and_then(|run| run.pending_request); + tick( + &mut plugin, + vec![applied_reply(9_999, 5, 5, 0.1)], + &mut sink, + ); + assert_eq!( + plugin.run.as_ref().and_then(|run| run.pending_request), + waiting_on, + "an unrelated reply must not settle the point" + ); + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::ApplyingBiases) + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn only_diff_on_and_diff_off_are_ever_changed_from_the_baseline() { + // The host contract carries a whole configuration, so the freeze on + // fo/hpf/refr/ROI/mask/trigger is no longer structural — A4 keeps it by + // cloning the confirmed baseline. That is exactly what this asserts: a + // point's configuration must differ from the baseline in two fields and + // nowhere else, or a threshold sweep could silently move the ROI. + let folder = temp_folder("narrow"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 5, 5); + + let snapshots = sink.applied_snapshots(); + assert_eq!(snapshots.len(), 1, "one point, one configuration"); + let mut expected = baseline_snapshot(); + expected.biases.diff_on = 5; + expected.biases.diff_off = 5; + assert_eq!( + snapshots[0], expected, + "a point must change the two biases and copy everything else forward" + ); + + // The session is opened by confirming what the bench is on, never by + // naming a profile — the survey measures the bench as it stands. + assert!( + sink.hosts.iter().any(|request| matches!( + &request.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current + } + )), + "the survey must open its session against the live configuration" + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn measurement_ids_are_file_safe() { + assert_eq!(sanitize_stem("a/b:c"), "a_b_c"); + assert_eq!(sanitize_stem(" "), "A4"); + // A generated id must already be file-safe, or every unnamed run would + // silently be filed under a sanitized variant of its own name. + let generated = generate_measurement_id(); + assert!(generated.starts_with("A4-"), "{generated}"); + assert_eq!(sanitize_stem(&generated), generated); + } + + #[test] + fn expected_codes_saturate_the_way_the_sensor_does() { + assert_eq!(expected_code(102, 12), 114); + assert_eq!(expected_code(10, -85), 0); + assert_eq!(expected_code(250, 140), 255); + } + + #[test] + fn compact_utc_formats_a_known_epoch() { + assert_eq!(format_compact_utc(1_767_225_600), "20260101-000000"); + assert_eq!(format_iso_utc(1_767_225_600), "2026-01-01T00:00:00Z"); + } +} diff --git a/plugins/stage-a-a4/src/sidecar.rs b/plugins/stage-a-a4/src/sidecar.rs new file mode 100644 index 0000000..c191ec7 --- /dev/null +++ b/plugins/stage-a-a4/src/sidecar.rs @@ -0,0 +1,226 @@ +//! The per-recording A4 sidecar, and the run-level protocol receipt. +//! +//! A threshold point is only worth keeping if it can answer, months later, +//! *which absolute bias codes were on the die while this file was written* — +//! and under what optical and thermal conditions. That is what this file is +//! for. It is written for every point, including the ones that failed, because +//! the record of a failed point is the reason the survey has a hole in it. +//! +//! Fields the sensor could not report are **absent**, never `0`. A die +//! temperature of 0 °C and "this camera has no temperature readback" are +//! opposite facts, and a reader six months from now cannot tell them apart from +//! a zero. + +use serde::Serialize; + +pub const SIDECAR_SCHEMA: &str = "stage-a.a4.sidecar.v1"; +pub const RECEIPT_SCHEMA: &str = "stage-a.a4.protocol-status.v1"; + +#[derive(Debug, Serialize)] +pub struct SidecarDoc { + pub schema: &'static str, + pub measurement_id: String, + pub recording: String, + pub recorded_at_utc: String, + pub plugin_version: &'static str, + pub protocol: ProtocolSection, + pub bias: BiasSection, + pub optics: OpticsSection, + pub sensor: SensorSection, + pub filters: FiltersSection, + pub camera: CameraSection, + pub files: FilesSection, + pub qc: QcSection, +} + +/// The protocol row this recording came from, copied verbatim, plus where in +/// the file it sat and which file that was. +#[derive(Debug, Serialize)] +pub struct ProtocolSection { + pub name: String, + pub file: String, + pub sha256: String, + /// 1-based, so it matches what the operator counts in the file. + pub row: usize, + pub rows_total: usize, + pub label: String, + pub repeat: u32, + pub repeats: u32, + pub requested_duration_s: i64, + pub requested_settle_s: f64, +} + +/// What was asked for, what was programmed, and what the sensor said it was +/// running. The three are kept separate on purpose: they are the same number +/// only when nothing went wrong, and this file exists to prove that. +#[derive(Debug, Serialize)] +pub struct BiasSection { + /// Offsets the protocol row asked for. + pub requested_diff_on: i64, + pub requested_diff_off: i64, + /// Offsets the host programmed, after its own range clamp. + pub applied_diff_on: i32, + pub applied_diff_off: i32, + /// Absolute 8-bit codes read back off the die. + pub code_diff_on: u8, + pub code_diff_off: u8, + /// The per-unit factory trim the offsets are relative to. + pub factory_diff_on: u8, + pub factory_diff_off: u8, + /// Codes for the biases A4 never touches, recorded so a reader can confirm + /// they were the same across the survey. + pub code_fo: u8, + pub code_hpf: u8, + pub code_refr: u8, + /// Seconds between the reconfigure and the reading that confirmed it. + pub readback_age_s: f64, + pub confirmed: bool, +} + +/// The optical condition, which A4 never changes and only records. +#[derive(Debug, Serialize)] +pub struct OpticsSection { + pub optical_state: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub filter_id: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub flux_id: String, + /// Whether the operator was asked to intervene before this point. + pub paused_for_operator: bool, +} + +/// Bench conditions at the two ends of the recording. Every field is optional; +/// a sensor that cannot report a quantity leaves it out. +#[derive(Debug, Serialize)] +pub struct SensorSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_c_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_c_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_lux_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_lux_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pixel_dead_time_us: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reading_age_s: Option, + /// Sensor lux is a stability indicator, not a calibrated optical power. + /// Stated in the file so nobody later reads it as one. + pub illumination_note: &'static str, +} + +/// The on-sensor filters, which must all be off for the counts to mean +/// anything. Recorded rather than assumed. +#[derive(Debug, Serialize)] +pub struct FiltersSection { + pub stc_enabled: bool, + pub trail_enabled: bool, + pub erc_enabled: bool, + pub erc_note: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct CameraSection { + pub roi_x: u16, + pub roi_y: u16, + pub roi_width: u16, + pub roi_height: u16, + pub masked_pixels: usize, + pub sensor_width: u16, + pub sensor_height: u16, +} + +#[derive(Debug, Serialize)] +pub struct FilesSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_size_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recorded_duration_s: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sensor_readout: Option, + /// True only for a clean `RecordingFinalized` with a plausible size, hash + /// and duration. A partial receipt is never complete, whatever survived. + pub complete: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub incomplete_reason: Option, +} + +#[derive(Debug, Serialize)] +pub struct QcSection { + pub status: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub flags: Vec, + pub on_events: u64, + pub off_events: u64, + pub total_events: u64, + /// Seconds of the recording the plugin actually observed events over. Less + /// than the recording duration when frames were dropped, so a reader can + /// see the coverage the rates were computed from rather than assuming it. + pub counted_seconds: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub off_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_fraction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_drift_c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_drift_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_temperature_drift_c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_illumination_drift_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_event_rate: Option, + pub rate_note: &'static str, +} + +// ---- run-level receipt ----------------------------------------------------- + +/// Written next to the copy of the protocol, so the folder says which rows ran +/// and which did not without anyone having to diff filenames against the file. +#[derive(Debug, Serialize)] +pub struct ProtocolReceipt { + pub schema: &'static str, + pub measurement_id: String, + pub protocol_name: String, + pub protocol_file: String, + pub protocol_sha256: String, + pub started_at_utc: String, + pub finished_at_utc: String, + pub outcome: String, + pub rows_total: usize, + pub rows_recorded: usize, + pub rows_failed: usize, + pub rows_flagged: usize, + /// The bias offsets the bench was on before the survey, restored afterwards. + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_diff_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_diff_off: Option, + pub biases_restored: bool, + pub row: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ReceiptRow { + pub row: usize, + pub label: String, + pub diff_on: i64, + pub diff_off: i64, + pub repeat: u32, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + pub qc: String, +} From 1b663e93b53c5dba655db89fce74a10309ff1308 Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Thu, 13 Aug 2026 17:31:43 +0200 Subject: [PATCH 10/11] =?UTF-8?q?docs:=20=F0=9F=93=9D=20index=20the=20two?= =?UTF-8?q?=20new=20Stage-A=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/features/README.md b/docs/features/README.md index 8fbad49..896c223 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -8,8 +8,10 @@ Repository-level feature notes for larger plugin suites, interface migrations, a - [Stage-A Modulation](./stage-a-modulation.md) — orthogonal Manual/Calibrated drive methods and five waveform modes under one hard DAC ceiling, applied immediately on the command port. The coupled `ū`/`a` controls **clamp into the achievable range instead of refusing**, so a leftover depth can no longer make an optical mode unselectable, and both live bounds are shown in the control labels (ADR 025). `V_peak` is the one operator-facing name for the lobe maximum; the half-wave span is derived and never entered. The undocumented TOML `MOD`-step protocol runner was removed — declarative recording protocols belong to A1 (ADR 027). Port discovery is platform-aware and shared with the photodiode plugin, so `auto` finds the Teensy on Windows' nameless `COMn` ports too (ADR 032). - [Stage-A Optical Waveform Drive](./stage-a-optical-waveform.md) — pre-warps the DAC from measured `V_null`/`V_peak` endpoints, with target-specific headroom, Bessel-normalized cycle mean `ū`, and an explicit separation from physical flux `I_k`. - [Stage-A Pockels Transfer Calibration](./stage-a-pockels-calibration.md) — one-button sweep of settled `CONST` DAC codes against the photodiode level, fitting `V_null`/`V_peak` from the light instead of a nominal datasheet, with a transfer-curve view that makes the two parameters legible before anything is measured. Each point is a 20 ms measurement after a 0.1 s settle, and every verdict on the sweep — lobe resolved, cell drifting — is made against the fit's own residual rather than against zero (ADR 019). Applying the fit now actually reaches the panel: the measurement lives on the live worker while the settings snapshot is collected from the UI mirror, so the applied lobe used to be overwritten within one frame (ADR 026). -- [Stage-A Photodiode](./stage-a-photodiode.md) — live SMA5/A4 readout plus fail-closed excitation log-contrast `a`, computed from complete phase-marker cycles in reject-port geometry. The total-power anchor `I_tot` is **learned from the detector's own stream** — the brightest reading it has taken is where the excitation is extinguished, which the Pockels sweep drives through by construction — so there is nothing to enter and nothing to confirm; the dark level is gone because a DC offset cancels exactly out of the complement (ADR 024). A refusal publishes its reason on the contract, rail detection is span-relative so the bench's millivolt-scale detector is not read as a clipped waveform, and the published level owns a fixed measurement window instead of borrowing the chart's averaging setting. Port discovery is platform-aware and shared with the modulation plugin (ADR 032). -- [Stage-A A1 Analysis](./stage-a-a1.md) — synchronized camera RAW + photodiode PDQ coordinator and fail-closed calibrated log-sine amplitude sweep, with transfer/anchor provenance, and live response quicklooks. The output folder is the only required input; ids are provenance and are filled in or recorded as `unspecified` rather than refusing a run. A **Depth `a` source** setting takes `a` from the photodiode (measured, default) or from the modulation owner's calibrated commanded drive (open loop), so a bench with no phase-0 markers can still run the workflow — and every artefact records which source it used (ADR 020). With a commanded depth there is nothing to search for, so `Find a₀` and the lock table drop out and the ladder confirms each frequency against the modulation owner instead of the camera trigger (ADR 021). Every run also records the sensor's own die temperature, pixel dead time and scene illumination (ADR 022). The frequency ladder is an outer loop: one button repeats the whole depth sweep at every planned frequency, producing the `q_p(a, f)` surface on a single lease (ADR 023). Recording, both sweeps and the a₀ workflow are one **Record** section — Record once / Sweep a / Sweep f / Sweep a × f / Stop — with Live analysis at the top of the panel rather than below the controls that read it. A **protocol** runs a whole survey from a file naming every axis for every recording — a CSV with one row per recording (per-row duration and a `normal`/`pilot`/`background` role, so a file carries its own references), or TOML blocks/ranges for a dense regular sweep — including the `I_k` axis that no button could sweep (ADR 027), and the host's sensor telemetry is compacted column-wise into the measurement folder under the run's own name (ADR 028). Every leased run heartbeats its modulation and photodiode leases against the deadline the owner actually granted, so a recording longer than the owner's TTL cap no longer loses the drive — and with it the phase-0 trigger and the photodiode's optical summary — in the middle of a point (ADR 029). +- [Stage-A Photodiode](./stage-a-photodiode.md) — live SMA5/A4 readout plus fail-closed excitation log-contrast `a`, computed from complete phase-marker cycles in reject-port geometry. The total-power anchor `I_tot` is **learned from the detector's own stream** — the brightest reading it has taken is where the excitation is extinguished, which the Pockels sweep drives through by construction — so there is nothing to enter and nothing to confirm; the dark level is gone because a DC offset cancels exactly out of the complement (ADR 024). A refusal publishes its reason on the contract, rail detection is span-relative so the bench's millivolt-scale detector is not read as a clipped waveform, and the published level owns a fixed measurement window instead of borrowing the chart's averaging setting. The raw ring **sizes itself to the drive** — the larger of the operator's cache length and nine marker-measured periods — because that window is the gate on `a`, and a cache length set for the wrong frequency otherwise costs a sub-hertz A1 survey one full-length recording at a time (ADR 033). Port discovery is platform-aware and shared with the modulation plugin (ADR 032). +- [Stage-A A1 Analysis](./stage-a-a1.md) — synchronized camera RAW + photodiode PDQ coordinator and fail-closed calibrated log-sine amplitude sweep, with transfer/anchor provenance, and live response quicklooks. The output folder is the only required input; ids are provenance and are filled in or recorded as `unspecified` rather than refusing a run. A **Depth `a` source** setting takes `a` from the photodiode (measured, default) or from the modulation owner's calibrated commanded drive (open loop), so a bench with no phase-0 markers can still run the workflow — and every artefact records which source it used (ADR 020). With a commanded depth there is nothing to search for, so `Find a₀` and the lock table drop out and the ladder confirms each frequency against the modulation owner instead of the camera trigger (ADR 021). Every run also records the sensor's own die temperature, pixel dead time and scene illumination (ADR 022). The frequency ladder is an outer loop: one button repeats the whole depth sweep at every planned frequency, producing the `q_p(a, f)` surface on a single lease (ADR 023). Recording, both sweeps and the a₀ workflow are one **Record** section — Record once / Sweep a / Sweep f / Sweep a × f / Stop — with Live analysis at the top of the panel rather than below the controls that read it. A **protocol** runs a whole survey from a file naming every axis for every recording — a CSV with one row per recording (per-row duration and a `normal`/`pilot`/`background` role, so a file carries its own references), or TOML blocks/ranges for a dense regular sweep — including the `I_k` axis that no button could sweep (ADR 027), and the host's sensor telemetry is compacted column-wise into the measurement folder under the run's own name (ADR 028). Protocols can now select one host-owned camera profile or inline snapshot and set per-point `diff_on`/`diff_off`; A1 waits for sensor readback and restores the pre-run state on success, Stop, and abort (ADR 037). Every leased run heartbeats its modulation and photodiode leases against the deadline the owner actually granted, so a recording longer than the owner's TTL cap no longer loses the drive — and with it the phase-0 trigger and the photodiode's optical summary — in the middle of a point (ADR 029). The sidecar's optical section is **latched while the recording runs** rather than read live when the metadata is written — the finalizes and the gather in between block A1's own tick, so a large recording used to lose its metadata to a freshness budget expiring on its own write-out time, and a refusal now quotes the photodiode's gate instead of naming the anchor (ADR 034). The 2 kHz firmware drive ceiling remains separate from A1's 16-samples-per-cycle photodiode gate (ADR 036). The four current laboratory CSVs are shipped as fixtures and integration-tested against A1 parsing/retarget order, the modulation owner's coupled calibrated-drive limits, and the photodiode's low-frequency ring capacity. +- [Stage-A A2 Latency](./stage-a-a2.md) — fail-closed fluorescence-chain step-latency protocol runner over the existing modulation/photodiode owners and host recorder; records synchronized RAW/PDQ provenance and both trigger polarities while leaving censored first-event fits offline. +- [Stage-A A4 Threshold Survey](./stage-a-a4.md) — reproducible `diff_on`/`diff_off` threshold measurements at one fixed optical condition, run unattended from a protocol. Every point is **confirmed against the sensor's own bias readback** before it records: the settings panel shows an offset around a per-unit factory trim, while the quantity the physics depends on is the absolute 8-bit code, so a point whose codes disagree — or whose confirming reading is missing or older than the change — is skipped rather than recorded wrong in a way nobody can detect later (ADR 035). It runs on the host's **generic camera-configuration session** — the host carries no A4-specific verb (augur-rs ADR 037) — so the freeze on `fo`, `hpf`, `refr`, the ROI, the mask and the trigger is kept by A4 itself: the run opens by having the host confirm the configuration the bench is on, every point is that snapshot with exactly two fields changed, and a test asserts the equality field by field. The host answers with a readback rather than an acknowledgement. Refusals and flags are split on purpose — the event filters being off, the codes being confirmed and the file being whole are **gates**; temperature drift, illumination drift and event rate are **flags** that mark a point and keep it, because whether a 2 °C drift invalidated a threshold is a judgement to make later with the file in hand. A limit whose quantity could not be measured is flagged rather than passed, so a camera with no temperature readback never reports every point as within a limit nobody checked. The biases the survey found are put back on completion, on Stop and on any abort, and the run does not close until that restore is answered. A1's CSV splitter and telemetry compactor moved into `stage-a-plugin-contract` so both workflows share one implementation (ADR 031). - [Stage-A A1 Automation](./stage-a-a1-automation.md) — roadmap to semi-automate the amplitude sweep; the single-row sweep core is **built** (ADR 010), scout/multi-row/`a50` fit remain planned. - [Stage-A A1 Exact Event Count](./stage-a-a1-event-count.md) — per-frequency `a₀` lock: closed-loop trim of the commanded depth until the photodiode *measures* the one frozen log contrast `a₀` over whole modulation cycles, a per-frequency lock table on disk, a one-button atomic frequency point recorded at exactly `a₀` under the modulation lease, and an unattended log-spaced frequency ladder that locks and records every planned `f` on a single lease. The search exists for the *measured* depth only — with a commanded depth the ladder skips it entirely and reduces to "set `a₀`, press Record all frequencies" (ADR 021). - [EVE Temporal Diagnostics](./evesmlm-temporal-diagnostics.md) — temporal candidate tracking, boundary overlays, and rejected-fit datasets for the eveSMLM pipeline. From 1e776c3b94fb271bad4b9360e17123016cb6c8bc Mon Sep 17 00:00:00 2001 From: Mika Uthmann Date: Fri, 14 Aug 2026 16:39:36 +0200 Subject: [PATCH 11/11] =?UTF-8?q?fix(stage-a-a1):=20=F0=9F=90=9B=20keep=20?= =?UTF-8?q?the=20protocol's=20remaining=20bench=20time=20on=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench time a survey still needs was announced once, in the transient message, and the first point's own message overwrote it a second later. An operator who looked away had no way to get it back. It belongs on the protocol's own status line, where it stays: the points still to run, settling included, counting the point in flight whole. `total_seconds` becomes `remaining_seconds(0)`, and one shared formatter gives both the opening message and the status line the same units. --- docs/features/stage-a-a1.md | 5 ++++- plugins/stage-a-a1/src/protocol.rs | 23 ++++++++++++++++++++ plugins/stage-a-a1/src/runtime.rs | 35 +++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index 39bc629..4b27e73 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -323,7 +323,10 @@ acceptance before high-frequency data is treated as qualified. - **Validated up front**: ranges, bounds, the `MAX_POINTS = 4096` product limit and the same whole-cycle window check the ladder makes against its lowest frequency — all on the button press, before the drive moves. The point count - and expected bench time are reported first. + and expected bench time are reported first, and the bench time still to run + stays on the protocol's own status line: the opening message is overwritten by + the first point, so an operator who looked away would otherwise never see it + again. - **A refused point is skipped, not fatal**, carrying the modulation owner's own wording. Because the per-point message is overwritten within the same tick, the reasons are kept on the run and shown in the status pane and the closing diff --git a/plugins/stage-a-a1/src/protocol.rs b/plugins/stage-a-a1/src/protocol.rs index b1f6f63..f1062f5 100644 --- a/plugins/stage-a-a1/src/protocol.rs +++ b/plugins/stage-a-a1/src/protocol.rs @@ -159,8 +159,20 @@ impl Protocol { /// Total bench time the protocol asks for, settling included. pub fn total_seconds(&self) -> f64 { + self.remaining_seconds(0) + } + + /// Bench time the points from `index` onwards still ask for, settling + /// included. The point at `index` counts whole: it is the one in flight, + /// and the recording's own countdown says how far into it the run is. + /// + /// Recording overhead (camera start/stop, the lease handshake, the a₀ + /// search) is not in here, so this is a lower bound on the wall clock — + /// the same quantity [`Self::total_seconds`] announces before the start. + pub fn remaining_seconds(&self, index: usize) -> f64 { self.points .iter() + .skip(index) .map(|point| point.duration_s as f64 + point.settle_s) .sum() } @@ -942,6 +954,17 @@ depth_a = 0.5 assert!((protocol.total_seconds() - (6.0 * 6.5 + 6.0 * 31.5)).abs() < 1e-9); } + #[test] + fn remaining_seconds_drops_the_points_already_done() { + let protocol = parse(SAMPLE).expect("valid protocol"); + // The point in flight counts whole, so after six 6.5 s points only the + // six 31.5 s ones are left. + assert!((protocol.remaining_seconds(6) - 6.0 * 31.5).abs() < 1e-9); + // Past the end nothing is left, rather than an index panic. + assert_eq!(protocol.remaining_seconds(protocol.points.len()), 0.0); + assert!((protocol.remaining_seconds(0) - protocol.total_seconds()).abs() < 1e-9); + } + #[test] fn out_of_range_values_name_the_axis_that_is_wrong() { let error = parse( diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index 2a322e4..10a894d 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -205,6 +205,19 @@ fn frequency_label(hz: f64) -> String { format!("{hz:.3} Hz") } +/// A stretch of bench time in the largest unit that still reads as a number an +/// operator can act on: seconds below two minutes, then minutes, then hours. +fn format_bench_time(seconds: f64) -> String { + let seconds = seconds.max(0.0); + if seconds < 120.0 { + format!("{seconds:.0} s") + } else if seconds < 5_400.0 { + format!("{:.0} min", seconds / 60.0) + } else { + format!("{:.1} h", seconds / 3_600.0) + } +} + /// Upper-cases the first character, so a blocker written as a sentence fragment /// ("the total power …") can also stand as its own sentence in the status panel. fn capitalize_first(text: &str) -> String { @@ -4641,10 +4654,10 @@ impl StageAA1Plugin { let (means, frequencies, depths) = plan.axis_counts(); let total = plan.points.len(); - let minutes = plan.total_seconds() / 60.0; + let bench_time = format_bench_time(plan.total_seconds()); self.message = format!( "Protocol '{}': {total} recordings ({means} × ū, {frequencies} × f, {depths} × a), \ - about {minutes:.0} min of bench time — preparing the camera and modulation lease…", + about {bench_time} of bench time — preparing the camera and modulation lease…", plan.name ); self.protocol = Some(ProtocolRun { @@ -7660,13 +7673,18 @@ impl Plugin for StageAA1Plugin { color: None, }]; if let Some(run) = self.protocol.as_ref() { + // The bench time left belongs on this line, not in the transient + // message: the message that announces it at the start is overwritten + // by the first point's own line, so an operator who looked away had + // no way to see how long the survey still runs. entries.push(StatusEntry::Text(format!( - "Protocol '{}': point {}/{} — {} recorded, {} skipped", + "Protocol '{}': point {}/{} — {} recorded, {} skipped, about {} of bench time left", run.plan.name, (run.index + 1).min(run.plan.points.len()), run.plan.points.len(), run.recorded, run.failed.len(), + format_bench_time(run.plan.remaining_seconds(run.index)), ))); // The per-point message is overwritten within the tick that skips a // point, so the most recent reason lives here instead of scrolling @@ -8077,6 +8095,17 @@ mod tests { } } + #[test] + fn bench_time_reads_in_the_unit_the_operator_needs() { + assert_eq!(format_bench_time(45.0), "45 s"); + assert_eq!(format_bench_time(119.0), "119 s"); + assert_eq!(format_bench_time(120.0), "2 min"); + assert_eq!(format_bench_time(3_600.0), "60 min"); + assert_eq!(format_bench_time(7_200.0), "2.0 h"); + // A finished survey reads as no time left, never as a negative one. + assert_eq!(format_bench_time(-1.0), "0 s"); + } + /// Mirrors the ordering of [`StageAA1Plugin::process_control`]. fn control_tick( plugin: &mut StageAA1Plugin,