diff --git a/.github/workflows/build-plugins.yml b/.github/workflows/build-plugins.yml new file mode 100644 index 0000000..94b29f3 --- /dev/null +++ b/.github/workflows/build-plugins.yml @@ -0,0 +1,219 @@ +name: Build Plugins + +# Produces drop-in plugin folders for a machine that has no Rust toolchain: the +# bench downloads a bundle, copies its folders into ~/.augur/plugins/, and hits +# "Scan for New Plugins". Pull requests get workflow artifacts; main also +# publishes a rolling release so the download needs no GitHub login. + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + inputs: + augur_rs_ref: + description: "augur-rs ref to build against (branch, tag or SHA)" + required: false + default: 142ccb3877151a172979c2cec6a5efee599831d0 + +concurrency: + group: build-plugins-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # The recording root API requires the paired host fix in augur-rs PR #53. + # Pin its tested commit for matching, reproducible host and plugin bundles. + AUGUR_RS_REF: ${{ inputs.augur_rs_ref || '142ccb3877151a172979c2cec6a5efee599831d0' }} + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + name: macOS (arm64) + bundle: macos-arm64 + - os: macos-15-intel + name: macOS (x86_64) + bundle: macos-x86_64 + - os: ubuntu-latest + name: Linux (x86_64) + bundle: linux-x86_64 + - os: windows-latest + name: Windows (x86_64) + bundle: windows-x86_64 + + defaults: + run: + # The repo drives its builds through two bash scripts; use the same + # shell on Windows so there is exactly one code path to reason about. + shell: bash + + steps: + # This workspace depends on the host by path (../augur-rs/augur-core), so + # CI has to reproduce the two-sibling-checkout layout, not clone one repo. + - name: Check out augur-plugins + uses: actions/checkout@v5 + with: + path: augur-plugins + + - name: Check out augur-rs + uses: actions/checkout@v5 + with: + repository: muthmann/augur-rs + ref: ${{ env.AUGUR_RS_REF }} + path: augur-rs + + - name: Pin the host revision and disarm the source patch + run: | + echo "AUGUR_RS_SHA=$(git -C augur-rs rev-parse HEAD)" >> "$GITHUB_ENV" + # build-runtime-plugins.sh patches [patch."…/augur-rs.git"] whenever a + # sibling augur-rs *git checkout* exists. This workspace already + # depends on it by path, so that patch matches nothing in the crate + # graph — it only costs cargo a fetch of the checkout. Removing .git + # makes the script's detection fail and the path deps win outright. + rm -rf augur-rs/.git + + - name: Resolve the pinned Rust toolchain + run: | + channel="$(sed -n 's/^channel *= *"\(.*\)"$/\1/p' augur-plugins/rust-toolchain.toml | head -n 1)" + if [[ -z "${channel}" ]]; then + echo "No channel found in augur-plugins/rust-toolchain.toml" >&2 + exit 1 + fi + echo "RUST_CHANNEL=${channel}" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_CHANNEL }} + cache-workspaces: augur-plugins + # The action injects RUSTFLAGS="-D warnings" by default. That is right + # for a lint job and wrong here: this job ships artifacts, and a dead- + # code warning in one plugin must not deny the bench a bundle for all + # of them. Lint gating belongs in its own job, not in the build. + rustflags: "" + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + # Only what the plugin crates actually link. augur-gui's own dependency + # script is deliberately not reused: it is a superset (X11/Wayland/GL for + # the GUI, which no plugin links) and it does not exist on every augur-rs + # revision this job can be pointed at, so borrowing it made the Linux + # build fail on the value of augur_rs_ref. serialport needs libudev. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends pkg-config libudev-dev + + - name: Test Stage-A acquisition and controller contracts + working-directory: augur-plugins + run: cargo test -p augur-plugin-stage-a-a1 -p augur-plugin-stage-a-a2 -p augur-plugin-stage-a-modulation -p augur-plugin-stage-a-photodiode -p stage-a-io -p stage-a-plugin-contract + + - name: Build runtime plugins + working-directory: augur-plugins + run: bash scripts/build-runtime-plugins.sh --profile release + + - name: Stage installable plugin folders + working-directory: augur-plugins + run: bash scripts/install-built-plugins.sh --profile release --dest "dist/${{ matrix.bundle }}" + + - name: Set up Python for the lab watcher + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Test the independent lab watcher + working-directory: augur-plugins + run: python scripts/test_stage_a_watch.py + + - name: Build the Windows lab watcher + if: runner.os == 'Windows' + working-directory: augur-plugins + run: | + python -m pip install pyinstaller==6.16.0 + python -m PyInstaller --noconfirm --clean --onefile --name Laborwache \ + --distpath dist/windows-x86_64/lab-watch --workpath target/lab-watch \ + --specpath target scripts/stage_a_watch.py + cp docs/features/stage-a-lab-watch.md dist/windows-x86_64/lab-watch/README.md + + - name: Write build provenance + working-directory: augur-plugins + run: | + { + echo "bundle: ${{ matrix.bundle }}" + echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "augur_plugins: $(git rev-parse HEAD)" + echo "augur_rs_ref: ${AUGUR_RS_REF}" + echo "augur_rs_sha: ${AUGUR_RS_SHA}" + echo "rustc: $(rustc --version)" + echo + echo "Copy the plugin folders next to this file into ~/.augur/plugins/," + echo "then use Plugins -> Scan for New Plugins in augur-gui." + } > "dist/${{ matrix.bundle }}/BUILD-INFO.txt" + + - name: Upload plugin bundle + uses: actions/upload-artifact@v4 + with: + name: augur-plugins-${{ matrix.bundle }} + path: augur-plugins/dist/${{ matrix.bundle }} + if-no-files-found: error + + release: + name: Publish rolling release + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download every plugin bundle + uses: actions/download-artifact@v4 + with: + path: bundles + pattern: augur-plugins-* + + - name: Package one archive per platform + run: | + set -euo pipefail + mkdir -p dist + for bundle_dir in bundles/augur-plugins-*/; do + bundle="$(basename "${bundle_dir%/}")" + (cd "${bundle_dir}" && zip -qr "${GITHUB_WORKSPACE}/dist/${bundle}.zip" .) + echo "Packaged ${bundle}.zip" + done + (cd dist && sha256sum ./*.zip > SHA256SUMS.txt) + ls -l dist + + - name: Publish rolling release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="plugins-latest" + # Delete and recreate rather than upload --clobber: it retags at the + # new commit and guarantees no asset from an older build survives. + gh release delete "${tag}" --yes --cleanup-tag || true + gh release create "${tag}" dist/* \ + --title "Prebuilt plugins (latest main)" \ + --notes "$(printf '%s\n' \ + "Prebuilt AugurRS plugins, rebuilt on every push to \`main\`." \ + "" \ + "- augur-plugins: \`${GITHUB_SHA}\`" \ + "- built against augur-rs \`${AUGUR_RS_REF}\`" \ + "" \ + "Download the archive for your platform, unpack it, and copy the" \ + "plugin folders inside into \`~/.augur/plugins/\`. Then open augur-gui," \ + "go to **Plugins**, and click **Scan for New Plugins**." \ + "" \ + "\`BUILD-INFO.txt\` in each archive records the exact revisions and" \ + "compiler the libraries were built with. Verify downloads against" \ + "\`SHA256SUMS.txt\`.")" diff --git a/.gitignore b/.gitignore index 39875ee..f240a43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/dist Cargo.lock *.swp *.swo diff --git a/Cargo.toml b/Cargo.toml index abb6328..f33ef05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,11 @@ [workspace] members = [ + "evesmlm-types", "stage-a-io", "stage-a-plugin-contract", "plugins/stage-a-a1", + "plugins/stage-a-a2", + "plugins/stage-a-a4", "plugins/stage-a-modulation", "plugins/stage-a-photodiode", "plugins/localization", @@ -27,6 +30,7 @@ augur-core = { path = "../augur-rs/augur-core" } augur-plugin-api = { path = "../augur-rs/augur-plugin-api" } augur-plugin-types = { path = "../augur-rs/augur-plugin-types" } egui = "0.27" +evesmlm-types = { path = "evesmlm-types" } rustfft = "6" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 346be75..da10175 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,31 @@ The plugin crates under `plugins/` are under active development and not yet read ## Quick Start +### Download Prebuilt Plugins (no toolchain needed) + +Every push to `main` publishes freshly built plugins for macOS (arm64 and x86_64), +Linux and Windows to the rolling +[`plugins-latest`](https://github.com/muthmann/augur-plugins/releases/tag/plugins-latest) +release. This is the recommended route for a measurement machine. + +```bash +curl -LO https://github.com/muthmann/augur-plugins/releases/download/plugins-latest/augur-plugins-macos-arm64.zip +unzip augur-plugins-macos-arm64.zip -d augur-plugins-bundle +mkdir -p ~/.augur/plugins +cp -R augur-plugins-bundle/*/ ~/.augur/plugins/ +``` + +Pick the archive matching the machine: `macos-arm64`, `macos-x86_64`, +`linux-x86_64`, or `windows-x86_64`. Then open `augur-gui`, go to **Plugins**, and +click **Scan for New Plugins**. + +Each archive contains a `BUILD-INFO.txt` recording the `augur-rs` revision and the +`rustc` version the libraries were built against — quote it in any ABI-mismatch +report. Verify downloads against `SHA256SUMS.txt` from the same release. + +Pull requests build the same bundles as workflow artifacts. See +[CI Prebuilt Plugin Bundles](./docs/features/ci-prebuilt-plugin-bundles.md). + ### Build One Plugin ```bash @@ -145,6 +170,7 @@ augur-plugins/ - [Plugin API Notes](./docs/plugin-api.md) — repo-local summary of the current runtime contract - [Installing Plugins](./docs/installing-plugins.md) — build, copy, reload, and troubleshoot installed plugins +- [CI Prebuilt Plugin Bundles](./docs/features/ci-prebuilt-plugin-bundles.md) — how the downloadable per-platform bundles are built and published - [Architecture Notes](./docs/architecture.md) — repository role, execution model, host views, and shared settings - [augur-rs Plugin Authoring Guide](https://github.com/muthmann/augur-rs/blob/main/docs/features/plugin-authoring-guide.md) — canonical host/runtime authoring guide - [augur-rs Global Settings Guide](https://github.com/muthmann/augur-rs/blob/main/docs/features/global-settings-menu.md) — host-owned settings published to plugins diff --git a/docs/adr/029-stage-a-leases-are-renewed-against-the-granted-deadline.md b/docs/adr/029-stage-a-leases-are-renewed-against-the-granted-deadline.md new file mode 100644 index 0000000..137878e --- /dev/null +++ b/docs/adr/029-stage-a-leases-are-renewed-against-the-granted-deadline.md @@ -0,0 +1,106 @@ +# ADR 029 — A leased run renews against the deadline the owner granted, not the one it asked for + +- **Status:** Accepted +- **Date:** 2026-08-03 +- **Relates to:** ADR 005 (device ownership), ADR 007 (owner orchestration), + ADR 009 (recording coordinator), ADR 027 (declarative protocols), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +Both Stage-A device owners hand out an automation lease with a TTL, and both +**cap** the TTL they grant: + +```rust +// modulation and photodiode, independently +const MAX_LEASE_TTL_MS: u64 = 60_000; +fn lease_deadline(ttl_ms: u64) -> u64 { + now_unix_ms().saturating_add(ttl_ms.clamp(MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS)) +} +``` + +The cap is a dead-man switch and it is right: an automation client that crashes +mid-run must not leave the laser driven indefinitely. A lease that lapses makes +the modulation owner queue `STOP` + `MOD wave=OFF`, and makes the photodiode +owner finalize its recording as `LeaseExpired`. + +A1 asked for a TTL covering its whole run — a frequency ladder, an amplitude +sweep, or a protocol file's remaining points — and renewed **once per point**, +in the same tick that retargeted the drive. The clamp is silent: the request is +answered `Applied`, so A1 believed it held the drive for forty minutes when the +owner had granted sixty seconds. + +That worked only while every point was shorter than the cap. It is not: + +- the shipped example protocol has a `duration_s = 40, settle_s = 4` row, and + every row also pays the camera start/stop and photodiode + connect/lease/start/finalize handshakes; +- `acquire_photodiode` asks for `duration_s + 60 s`, so **any recording longer + than the cap** outlived its own photodiode lease. + +Past the granted deadline, one root cause surfaced as three unrelated-looking +failures in the same status line: + +| symptom | actual cause | +| --- | --- | +| `the modulation owner requires an active automation lease` | the lease was reaped and the drive safe-offed | +| `cannot write a quantitative A1 sidecar without a fresh photodiode optical summary` | no drive → no modulated light, and the PDQ had been finalized as `LeaseExpired` | +| `Camera: … events, … no trigger signal` | no drive → the Teensy stopped emitting the phase-0 `EXT_TRIGGER` | + +The third is the one that reads as a hardware fault. It sent the operator after +a trigger cable that was never disconnected. + +## Decision + +**The owner's cap stays. The client renews against the deadline the owner +publishes.** + +Both owners already advertise the truth: `ModulationStateV1.lease` and +`PhotodiodeSummaryV1.lease` carry a `LeaseSnapshotV1 { lease_id, holder, +expires_at_unix_ms, .. }`. A1 never read it. + +A1 gains one heartbeat, `drive_lease_heartbeat`, running on every control tick +ahead of the runners: + +- it finds the modulation lease A1 currently holds — outermost runner first, + since a nested run inherits the enclosing lease id — and the photodiode lease + of a recording in flight; +- it renews only what the **owner's own snapshot** confirms A1 is holding, so a + lease the owner has already dropped is not chased; +- it renews once less than `LEASE_RENEW_MARGIN_MS` (20 s) of the granted window + is left, no more often than every `LEASE_RENEW_MIN_INTERVAL_MS` (2 s) — the + control plane ticks at 20 Hz and the owner's snapshot lags a renewal by a tick + or two. + +The per-point renewals stay. They are correct and they cost nothing; the +heartbeat covers the interval between them. + +The whole-run TTL helpers stay too, and keep asking for the run's real remaining +time. That is the honest statement of need, and it is the owner's job — not the +client's — to decide how much of it to grant. + +## Consequences + +- A point may now be arbitrarily long. The protocol's `duration_s` is bounded + by the protocol schema (1..=3600 s), not by an owner's lease cap. +- The dead-man switch is intact: if A1 stops ticking, the heartbeat stops with + it and the lease lapses within the cap, exactly as before. +- A lease A1 loses anyway (owner restart, an operator disconnect) is not + papered over. The heartbeat goes quiet because the owner's snapshot no longer + names A1 as the holder, and the runner's own retarget reports the real + failure in its own words. +- Renewal replies are not routed to any runner. An unmatched `request_id` + already falls through `on_service_reply` untouched, so a heartbeat cannot + be mistaken for a point's retarget outcome. +- The owners were left alone. Raising `MAX_LEASE_TTL_MS` to survey length would + have fixed the symptom by deleting the safety property that motivated it. + +## Also fixed here + +`on_discontinuity` asked `recording.is_active() || sweep.is_some()` to decide +whether a `SourceChanged` was self-inflicted. Starting and stopping the host +recorder raises it twice per recording, and between two points of a protocol or +a frequency ladder neither of those is true — so the run's own boundary was +treated as an idle-time reset and wiped the survey's pilot windows, background +floor and response curve mid-run. The question is now `automation_active()`: +the same set `request_stop` winds down. diff --git a/docs/adr/030-prebuilt-plugin-bundles-from-ci.md b/docs/adr/030-prebuilt-plugin-bundles-from-ci.md new file mode 100644 index 0000000..d00a3dc --- /dev/null +++ b/docs/adr/030-prebuilt-plugin-bundles-from-ci.md @@ -0,0 +1,90 @@ +# ADR 030 — Prebuilt plugin bundles are produced by CI, not by the bench + +**Status:** accepted +**Date:** 2026-08-04 +**Feature brief:** [CI Prebuilt Plugin Bundles](../features/ci-prebuilt-plugin-bundles.md) + +## Context + +A runtime plugin is a `cdylib` plus a `plugin.toml`. Getting one onto a machine +required a Rust toolchain, a sibling `augur-rs` checkout, and `cargo`, because +this workspace depends on the host by path. That made the measurement PC a +development machine by necessity: every plugin fix had to be compiled where it +was used. + +Two properties of the plugin model make "just compile it there" worse than it +looks. Plugins are dlopened into the host process, so the compiler that builds a +plugin and the compiler that builds `augur-gui` have to agree — and this +repository pinned no toolchain at all while `augur-rs` pinned `1.95.0`. And the +installed folder is not just a library: A1 ships operator-facing `protocols/` +examples, and macOS copies need their dylib id rewritten or Plugin Manager +reloads resolve back into Cargo's build tree. + +## Decision + +CI builds the runtime plugins on every pull request and every push to `main`, for +macOS arm64, macOS x86_64, Linux x86_64 and Windows x86_64, and publishes the +result as a folder that is copied verbatim into `~/.augur/plugins/`. + +Three things follow from that, and they are the actual decision: + +1. **This repository pins the host's toolchain.** `rust-toolchain.toml` carries + the same `1.95.0` as `augur-rs`, and the workflow reads the channel out of + that file instead of naming a version in YAML. A bundle built by a different + compiler than the host is not a bundle, it is a load failure waiting to + happen, and the pin is the only thing that makes that guarantee checkable. + +2. **CI runs the repository's own build and install scripts.** It does not + reimplement plugin discovery, library naming, the `protocols/` copy or the + macOS install-name rewrite in YAML. The scripts are the single definition of + what an installed plugin is; CI is one more caller of them, with + `--dest dist/` instead of `~/.augur/plugins`. + +3. **`main` publishes a rolling release, not just artifacts.** Workflow artifacts + need a GitHub login and expire after 90 days. The bench is the consumer, and + it should be able to `curl` a URL. The tag `plugins-latest` is deleted and + recreated on every push to `main`, so its assets can never be a mixture of two + builds. + +Every bundle carries a `BUILD-INFO.txt` recording the `augur-plugins` commit, the +`augur-rs` ref and SHA, and the exact `rustc` version. + +## Consequences + +- The measurement PC needs no toolchain, no checkout, and no `cargo`. +- An ABI-mismatch report from the bench is now answerable: the provenance file + says which host revision and compiler the installed library came from. +- Local builds in this repository move from whatever `rustc` is on `PATH` to the + pinned `1.95.0` — but only for people whose `cargo` is the rustup shim. A + Homebrew `cargo` earlier on `PATH` ignores `rust-toolchain.toml` entirely and + will keep producing plugins for a compiler the host does not use. +- The macOS bundles are per-architecture while `augur-gui` ships universal, so + the download page has one more choice on it than the host's does. +- `main` gains a permanent release tag. The repository had no releases before, so + `releases/latest` now resolves to `plugins-latest`; a future versioned release + scheme would have to account for that. + +## Alternatives considered + +**Publish only workflow artifacts.** Simplest, and rejected: it puts a GitHub +login between the bench and a fix, and the artifact disappears after 90 days. + +**Build against the newest `augur-rs` release tag, or against `main`.** Both were +rejected by fact rather than by preference: `augur-rs` `main` does not carry the +`TableSchema`, host-view or dataset-descriptor API these plugins already use, so +either choice is a guaranteed red build. The default host ref is therefore the +open host branch that does carry it, and `BUILD-INFO.txt` records the exact ref +and SHA behind every library so the coupling stays visible. This is temporary by +construction: the default moves to `main` in the same commit that the host API +lands there. + +**Reimplement the install layout in the workflow.** Would have avoided calling +shell scripts from YAML, at the cost of a second, silently divergent definition +of what an installed plugin contains. The `protocols/` folder and the macOS +install-name rewrite were both added to the script after the fact; a YAML copy +would have missed both. + +**`lipo` the two macOS builds into universal libraries.** Attractive, since the +host is universal, but `install-built-plugins.sh` reads `target/` only +and a cross-build lands in `target//`. Deferred rather than +special-cased in CI, since it belongs in the script if it is worth doing. diff --git a/docs/adr/031-evesmlm-plugins-share-a-types-crate.md b/docs/adr/031-evesmlm-plugins-share-a-types-crate.md new file mode 100644 index 0000000..72b24b4 --- /dev/null +++ b/docs/adr/031-evesmlm-plugins-share-a-types-crate.md @@ -0,0 +1,101 @@ +# ADR 031 — Plugins share a types crate, never each other + +**Status:** accepted +**Date:** 2026-08-04 +**Supersedes:** the "shared types are exported from the producing plugin's crate" convention +**Feature brief:** [eveSMLM Pipeline](../features/evesmlm.md) + +## Context + +The eveSMLM plugins form a chain: fitting consumes what candidates publishes, +post-processing consumes what fitting publishes. The repository convention was +that shared types are exported from the producing plugin's crate, so +`augur-plugin-evesmlm-fitting` depended on `augur-plugin-evesmlm-candidates`, and +`augur-plugin-evesmlm-postproc` depended on fitting. + +Plugin crates are built as `crate-type = ["cdylib", "rlib"]`, and each one +invokes `export_plugin!`, which emits `#[no_mangle] augur_plugin_vtable`. A +plugin that depends on another plugin therefore links that plugin's rlib — and +its vtable symbol — into its own `cdylib`. + +The Apple linker tolerates the duplicate. `rust-lld` and MSVC's `link.exe` do +not: + +``` +rust-lld: error: duplicate symbol: augur_plugin_vtable +LNK2005: augur_plugin_vtable already defined … fatal error LNK1169 +``` + +This was invisible for as long as the only build machine was a Mac. It surfaced +the first time CI built the repository on Linux and Windows (ADR 030): macOS +arm64 produced a complete bundle while both other platforms failed to link. +Every non-macOS user was locked out of the eveSMLM chain, and Stage-A users were +locked out of a Windows bundle entirely, because the build is all-or-nothing. + +## Decision + +**A plugin crate may not depend on another plugin crate.** Everything that +crosses a plugin boundary lives in a plain library crate that exports no vtable. + +For eveSMLM that crate is `evesmlm-types`, holding the wire contract +(`EveEvent`, `EveCluster`, `EveCandidates`, `EveLocalization`, +`EveLocalizationResults`, `FitMethod`, the `CTX_*` channel names) and the +current-localization dataset surface that both fitting and post-processing +publish (`current_localizations_registry_for_results`, +`current_localizations_dataset`, `localization_row_id`, +`to_localization_results`, the `CURRENT_LOCALIZATIONS_*` ids). + +Plugin-private types stay in their plugin: the candidate tracker's +`TrackedCluster` moved back out of the shared crate into +`plugins/evesmlm-candidates/src/tracking.rs`. The test is whether another plugin +names the type, not whether it happens to sit next to one that does. + +Each plugin keeps re-exporting the shared names it used to own, so downstream +`use augur_plugin_evesmlm_fitting::EveLocalization` keeps compiling. + +## Consequences + +- The eveSMLM chain links on Linux and Windows, so CI can produce bundles for all + four platforms rather than macOS only. +- Every plugin `cdylib` exports exactly one `augur_plugin_vtable`, which is what + the host's loader assumes in the first place. +- `stage-a-plugin-contract` was already built this way for the Stage-A owner + plugins (ADR 005/006). This generalizes that pattern instead of treating it as + a Stage-A peculiarity. +- The repository convention in `CLAUDE.md` and `CONTRIBUTING.md` — "shared types + between plugins should be exported from the producing plugin's crate" — is + wrong as stated and is replaced by this ADR. +- One more crate per plugin family. That is the cost of the rule, and it is + smaller than the cost of a platform-specific link failure that only shows up + on a machine nobody builds on. + +## The rule covers dev-dependencies (2026-09-07) + +A `[dev-dependencies]` edge onto a plugin crate links that plugin's vtable into +the *test* binary, and it fails exactly the same way. The modulation and +photodiode owners each had one onto the A1 plugin, to validate the shipped A1 +protocols against their own limits. Nothing caught it because the workflow only +built `cdylib`s; the first job that compiled a test target on Linux and Windows +failed to link. + +The A1 recording protocol parser therefore lives in `stage-a-plugin-contract` +(`protocol`), re-exported by the A1 crate so `crate::protocol::…` and +`augur_plugin_stage_a_a1::protocol::…` both keep working. The build workflow +now runs the Stage-A tests on every platform, so the next such edge fails in CI +rather than on the bench. + +## Alternatives considered + +**Feature-gate `export_plugin!` and have dependents disable it.** Would keep the +plugin-to-plugin dependency. Rejected: cargo unifies features across a workspace +build, so the `cdylib` target and the same crate consumed as an rlib dependency +resolve to one feature set — the vtable would be on for both, or off for both. + +**Duplicate the shared type definitions in each plugin.** No new crate, and no +shared contract either: the two copies would drift, and the published JSON is +exactly what must not drift. + +**Build the eveSMLM plugins only on macOS.** Considered because the bench PC that +needed a Windows bundle runs Stage-A, not eveSMLM. Rejected: it encodes a +linker accident as a platform policy, and it leaves the bug in place for the +next plugin family that chains. diff --git a/docs/adr/032-teensy-port-discovery-is-platform-aware.md b/docs/adr/032-teensy-port-discovery-is-platform-aware.md new file mode 100644 index 0000000..64e57ca --- /dev/null +++ b/docs/adr/032-teensy-port-discovery-is-platform-aware.md @@ -0,0 +1,114 @@ +# ADR 032 — Teensy port discovery is platform-aware and lives in `stage-a-io` + +**Status:** accepted +**Date:** 2026-08-04 +**Feature briefs:** [Stage-A Modulation](../features/stage-a-modulation.md), [Stage-A Photodiode](../features/stage-a-photodiode.md) + +## Context + +Both Stage-A owner plugins find the Teensy by enumerating serial ports and +probing the candidates: the modulation plugin opens each and keeps the one that +answers `HELLO` (the command port), the photodiode plugin listens on each and +keeps the one streaming CRC-clean PDA1 sample frames (the stream port). The +probe is what identifies the device; enumeration only decides what gets probed. + +That candidate filter was written on a Mac and hard-coded the two Unix name +patterns: + +```rust +name.contains("cu.usbmodem") || name.contains("ttyACM") +``` + +Windows names no device. Every serial port is `COMn`, so a correctly attached, +correctly driven Teensy matched neither pattern and was filtered out *before* +any probe could run. Both plugins then reported + +``` +no USB serial device found (looked for usbmodem/ttyACM) +``` + +which names the two things the machine cannot produce, so it reads as "nothing +is attached" when the device is in fact attached and enumerated. The first +Windows bundles from CI (ADR 030) made this reachable for the first time. + +The filter existed in four places — `serial_ports()` and `port_variants()` in +each plugin — and had drifted into two implementations: modulation went through +`stage-a-io`, photodiode called `serialport` directly with `stage-a-io`'s +`hardware` feature switched off. + +## Decision + +**One platform-aware candidate filter, in `stage-a-io::transport`.** + +`available_ports()` returns `PortInfo { name, label, is_usb }` — the OS path, +the USB manufacturer/product label where the OS reports one, and whether the OS +classified the port as USB at all. `candidate_ports()` narrows that list: + +- **macOS** — `cu.usbmodem*`. Every device is listed twice (`tty.*` and `cu.*`) + and only the callout node may be opened, so the name filter is also a dedupe. +- **Linux** — `ttyACM*`, the CDC-ACM class node a Teensy enumerates as. +- **Windows** — every USB-classified port. The name carries no device + information, so USB-ness is the only signal available. If the OS classified + *no* port as USB, the whole list is probed rather than none: a missing + SetupAPI classification must not be able to hide the device the way the name + filter did. + +Both plugins call `candidate_ports()` for probing and `PortInfo::variant()` for +the settings picker, so the probed set and the listed set cannot disagree. +The photodiode crate enables `stage-a-io`'s `hardware` feature to reach it; +`serialport` was already a direct dependency there, so nothing new enters the +build. + +**The filter is a probe-cost optimisation, not the identity check.** It exists +to keep the probes off unrelated ports — notably Windows' phantom Bluetooth +`COM` entries, which can block on open. Being too permissive costs a few +hundred milliseconds of probing; being too strict makes the hardware +unreachable. When in doubt, probe. + +`no_candidate_ports_message()` replaces the fixed string with what the OS +actually enumerated, distinguishing "no serial ports found" from "no serial +port looked like a Teensy (the OS offered COM1 (Bluetooth), …)". + +The platform branch is a `windows: bool` parameter to a private +`narrow_to_candidates`, not a `#[cfg]`, so the unit tests cover both branches +from any build host — including the Windows regression that motivated this ADR. + +**Every port is opened with `dtr_on_open(true)`.** macOS and Linux assert DTR +when a tty is opened; Windows does not — `serialport` sets +`DTR_CONTROL_DISABLE` in the DCB. A Teensyduino sketch that gates its output on +`if (Serial)` (which is `usb_configuration && usb_cdc_line_rtsdtr`) therefore +stays silent on Windows even once the right port is found, and the probes would +report "no port streamed PDA1 sample frames" on a working device. Asserting DTR +on all three platforms makes the port behave the same everywhere; on macOS and +Linux it is a no-op. + +## Consequences + +- The Stage-A plugins connect on Windows: the ports are found, and the opened + port has DTR asserted the way the Unix platforms already did implicitly. +- A port picker entry and a probe candidate come from one function, so a port + that appears in the dropdown is one `auto` would also have found. +- The failure message names the enumerated ports, which is the difference + between "check the cable" and "the filter dropped my device". +- `available_port_names()` and `available_ports_with_labels()` are replaced by + `available_ports()`. Both were internal to this repository. +- Windows probes any non-USB port when the OS classifies nothing at all, which + can add probe latency on a machine with legacy `COM` hardware. Accepted: an + unreachable device is worse than a slow scan. + +## Alternatives considered + +**Match the Teensy by USB VID/PID (0x16C0).** The most precise filter, and it +would work identically on all three platforms. Rejected for now: it hard-codes +the vendor of one board revision into the discovery path, and the probes +already establish identity positively — a VID match that skipped probing would +still have to tell the two ports of the dual-serial device apart. + +**Probe every enumerated port on every platform.** Simplest possible rule, and +correct. Rejected: on macOS it would open the `tty.*` twin of each device, +which blocks waiting for carrier detect, and on Windows it would sit on +phantom Bluetooth ports. + +**Keep the filter in the plugins and add a Windows arm to each.** Rejected: it +was already four copies in two implementations, and the copy that broke was +the one that had drifted. 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/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/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..511c651 --- /dev/null +++ b/docs/adr/037-stage-a-a1-camera-configurations-and-bias-points.md @@ -0,0 +1,60 @@ +# ADR 037 — A1 protocols apply host camera configurations and point biases + +- **Status:** Accepted +- **Date:** 2026-08-12 +- **Relates to:** ADR 027 and `augur-rs` 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 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 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 +snapshot, and profile provenance. The host restores a full configuration +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. 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 + +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/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/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/adr/040-stage-a-bring-up-automation.md b/docs/adr/040-stage-a-bring-up-automation.md new file mode 100644 index 0000000..f75387e --- /dev/null +++ b/docs/adr/040-stage-a-bring-up-automation.md @@ -0,0 +1,99 @@ +# ADR 040 — Bring-up values are sorted by origin, not typed into one file + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Relates to:** ADR 011 (Pockels transfer calibration), ADR 016 (lobe endpoints), + ADR 019 (calibration measures its own window), ADR 027 (declarative protocols), + ADR 038 (A2 protocol runner), ADR 039 (A1 sidecar owns experiment provenance) + +## Context + +The A2 protocol is the aggregate root (ADR 038) and it fails closed, which is +correct. But it asks for every bring-up value in the same way: as a TOML field +the operator types at the bench. `a2_fluorescence_chain_followup.toml` demands +around eighteen of them, and filling it feels like busywork because three +unrelated problems have been conflated into one. + +| kind | examples | where it actually lives | +| --- | --- | --- | +| an owner already knows it | `v_null_dac`, `v_peak_dac`, the `min_half_us` floor, camera-profile flags | not in the file at all | +| the result of another measurement | `h4_loopback_id`, `h5_polarity_calibration_id`, `optical_edge_calibration_id`, `local_flux_calibration_id` | a registered calibration artifact, referenced by name | +| genuinely human | filter/splitter part numbers, sample identity, "the beam is blocked" | panel inputs and a guided step list | + +The first kind is the same duplication ADR 039 removed for camera and bias +settings, where the host camera sidecar became the single authoritative source. +The contract already publishes what A2 asks the operator to retype: +`OpticalDriveStateV1` carries the resolved lobe endpoints and +`ModulationStateV1::calibration_id` says which measured inversion produced them +(`None` means a hand-entered lobe); `PhotodiodeLevelV1` carries the plateau +levels with an `end_sample_index` that proves a level was measured *after* the +drive was commanded; `PhotodiodeDarkReferenceV1` already generates a traceable +ID; and the A2 runner already enforces `min_half_us >= 5 * pixel_dead_time_us` +from sensor telemetry it makes the operator type anyway. + +## Decision + +A2 bring-up values are treated according to their origin. + +**Kind 1 is resolved from owners.** The lobe endpoints, the `min_half_us` floor +and the camera-profile flags leave the protocol file and are read from the owner +snapshot at preflight. The run records the resolved values together with the +owner's own `calibration_id`, so provenance is not lost by not typing them. + +**Kind 2 becomes a registered calibration artifact.** The four `*_id` gate +strings are replaced by a table naming the calibration that produced them. An +artifact carries its own `optical_config_id`, creation time and validity, and is +accepted only when the configuration matches and it is fresh. + +**Kind 3 moves into the plugin panel** as typed inputs and an ordered, gated +step list, entered once per frozen optical configuration rather than once per +measurement. The bench operator should not open a TOML. + +**This strengthens the gates rather than relaxing them.** Today `real_id()` +checks only that a string is non-empty, is not `"TBD"` and does not contain +`"REPLACE"`. `"asdf"` passes. An H5 polarity calibration from a *different* +optical configuration passes, and nothing downstream would ever notice. An +artifact that names its own `optical_config_id` is machine-checkable; a hand-typed +string is not. Less typing and a sharper check are the same change here. + +**A1 and A2 remain separate runners.** Different trigger sources (J24 phase-0 +versus comparator), different firmware modes, different estimands, separately +tested. The defect is the *handoff* between them, and a calibration artifact is +what fixes a handoff. Merging the runners would couple a 2.6 h acquisition to a +multi-hour one for no gain. + +**Resolution never weakens a gate.** A value that cannot be resolved must still +refuse. `Option` and a refusal, never `unwrap_or_default()`. Convenience that +removes a refusal is a regression, not a feature. + +**Calibration protocols are referenced explicitly**, resolved relative to the +referring protocol. Not by scanning a folder: implicit collection eventually +picks up a foreign file, and it cannot be audited afterwards. + +**The panel is UI state, not provenance.** What a run cites still comes from +artifacts and owner snapshots, never from what the panel happened to be +displaying when the operator pressed the button. + +## Consequences + +The protocol file states what the *experiment* is and stops restating what the +instrument already knows. A gate can report itself as resolved, stale or missing +with the artifact ID that decided it, which is not something a `TBD` string can +do. + +What remains for the operator is irreducible, and is entered once per frozen +optical configuration: + +- what is physically installed that no instrument reports — filter and splitter + part numbers, the measured split at the fluorescence wavelength, field-stop + geometry, the sensor-plane power bound; +- sample identity and history; +- confirmation of physical acts — beam blocked, ND swapped, loopback connected. + These stay pause-before steps, because a click is the cheapest honest + acknowledgement that a hand moved something; +- scope-derived edge numbers (H19/H3). A 500 kSa/s ADC and 1 µs markers cannot + resolve a sub-microsecond edge, so this stays a human reading — but a human + reading recorded once as an artifact, not retyped per measurement. + +Existing protocol files must continue to parse and continue to refuse: no +fixture may lose its fail-closed behaviour on the way through. diff --git a/docs/adr/041-stage-a-a2-drive-sync-capture.md b/docs/adr/041-stage-a-a2-drive-sync-capture.md new file mode 100644 index 0000000..d2754d4 --- /dev/null +++ b/docs/adr/041-stage-a-a2-drive-sync-capture.md @@ -0,0 +1,46 @@ +# ADR 041 — Separate A2 synchronized capture from optical timing qualification + +- Date: 2026-09-06 +- Status: Accepted in source; hardware qualification pending + +## Context + +A2's comparator preparation could reject correct firmware readback and used a rate +unsupported by the portable CONFIG sampler. The runner also issued START during a +continuous PD stream. Strong optical noise makes an online voltage threshold a poor +acquisition prerequisite. A shared electrical anchor and simultaneous raw PD samples +permit an independent optical-edge analysis after recording. + +## Decision + +Add explicit `timing_reference` (`comparator` default or `drive_sync`) and +`trigger_validation` (`strict` default or `offline_review`) to point protocols. +Production selects drive sync and offline review. It uses calibrated square endpoints, +J24 camera pulses and source-1 PD markers. Both recorders open during a quiet +interval before a new pulse train starts, avoiding an ambiguous whole-cycle offset. +It does not arm the comparator or fit t50. +Keep raw storage/stream and owner lifecycle failures fatal under both policies. +Record timing warnings, marker-loss counters and missing evidence without calling +those points qualified. Separate lease renewals from acquisition acknowledgements. + +Extend version-1 service payloads with defaulted timing reference and optional marker +diagnostics/counts. Old JSON remains decodable; old firmware or owners supply no +DMA-clock evidence and cannot silently satisfy the new timing qualification. +A2 sidecars advance to schema 2. No PDA1 wire-format change is required. +Use DMA-cursor marker indexing in production firmware and expose its method via STATUS. + +## Consequences + +J24 wiring and matching firmware must be verified at the bench. Optical timing is +still measured from the concurrent emission PD after fitting the two device clocks. +The pulse's trailing edge cannot represent optical OFF. A separate dark/noise reference +cannot remove a later random noise realization. Low-SNR captures can remain useful, +but an unresolved optical time origin limits absolute latency and intrinsic jitter. +Existing comparator protocols and their stricter intent remain supported. + +A2 mean_u retains its geometric-pedestal meaning. Matched production templates convert +cycle-mean targets into geometric pedestals explicitly, preserving old protocols. +Tests cover command grammar/readback, owner/mock prepare, waveform-off cleanup, +reply routing, malformed/empty/short receipts, low-SNR threshold diagnostics, raw marker +round trips, schedule coverage and DMA-clock arithmetic. Software tests do not +substitute for the end-to-end bench smoke or H4/H5 timing measurements. diff --git a/docs/adr/042-photodiode-recording-writes-off-the-reader-thread.md b/docs/adr/042-photodiode-recording-writes-off-the-reader-thread.md new file mode 100644 index 0000000..e220594 --- /dev/null +++ b/docs/adr/042-photodiode-recording-writes-off-the-reader-thread.md @@ -0,0 +1,78 @@ +# ADR 042 — The photodiode recording is written off the reader thread + +- Status: accepted +- Date: 2026-09-07 +- Supersedes: nothing. Affects the recording path behind + [ADR 007](./007-stage-a-owner-orchestration.md) evidence files, and therefore + every A1/A2 point ([ADR 038](./038-stage-a-a2-protocol-runner.md), + [ADR 041](./041-stage-a-a2-drive-sync-capture.md)). + +## Context + +The photodiode owner reads the free-running PDA1 stream on one thread. That +thread also wrote the `.pdq` evidence file: `record_frame` called +`PdqWriter::write_frame` inline, through an 8 KiB `BufWriter`, so roughly every +second wire frame turned into one small write to the storage target — which at +the bench is a network share. + +The firmware keeps **two** DMA blocks of 2048 samples. At the bench rate of +500 kSa/s that is about 8 ms of slack: a block whose frame cannot be staged +while the previous one still drains is discarded whole and counted +(`stream_dropped_samples`). Every millisecond the reader spends inside a write +is a millisecond in which the device can overrun. + +That is what a stalled write costs, measured on the retained evidence of +2026-09-07 (`A2-…_core_floor_pre`, two consecutive attempts): + +- The device dropped 176 128 and 178 176 samples — 2 to 4 losses of 12 to + 283 ms each — while `crc_failures`, `resync_bytes` and the frame sequence + stayed clean. Nothing was lost on the wire; the device threw the blocks away. +- The firmware drop counter did **not** move in the ~100 s between the two + recordings. The losses happened only while a recording was open. +- A `.pdq` with a hole is not one contiguous segment, so + `FrameTracker::contiguous_sample_range` yields `None`, the finalized receipt + carries no `sample_range`, and A2 refuses the point with *"PDQ does not cover + the requested 30.000 s: sampled duration=None s"* — after the full 30 s ran. + The A2 identification points need 100 s contiguous, so this is not a + wait-and-retry situation. + +The same host wrote 30 clean 20 s recordings to the same share on 2026-08-14, +so this is storage latency, not a rate ceiling. Any target can stall: a share, +a busy disk, a virus scanner. The reader must not be the thread that waits. + +## Decision + +`RecordingWriter` owns the `PdqWriter` on a thread of its own. `record_frame` +hands over a frame through a bounded queue and returns immediately. + +- The queue holds `WRITER_QUEUE_FRAMES = 1024` frames — about 8 s of stream at + 500 kSa/s, 4 MB of memory — so storage may stall for seconds without the + reader ever waiting. +- `try_send` is used, never `send`. A full queue is reported as + `write_error` ("recording queue overflow after N samples"), which makes the + receipt invalid and fails the point. It is never a reason to block: waiting + would produce exactly the segmented file this ADR exists to prevent. +- The writer thread keeps draining after a write failure, so a broken target + cannot make the queue fill up and stall the reader indirectly. +- `finish` closes the queue, joins the thread, and only then flushes and + finalizes the file. Finalizing runs on the caller's thread, which is the + plugin thread at the end of a point — not the reader. +- The evidence writer's buffer grows from the `BufWriter` default of 8 KiB to + 1 MiB, so a recording reaches storage in about one write per second instead + of some sixty. + +## Consequences + +- A storage stall no longer costs samples. It costs queue depth, and only a + stall longer than the queue costs the recording — with a named error instead + of a silent hole. +- `samples_written` and the marker counts now count **enqueued** frames. They + stay truthful for a recording that finalizes cleanly; a recording that does + not is invalid anyway, through `write_error`. +- Memory per active recording rises by up to 4 MB. Only one recording is open + at a time. +- The written bytes are unchanged: the same frames in the same order, and the + file-level CRC32/SHA-256 are still computed by the writer. +- This removes the host from the critical path, not the firmware's two-block + ceiling. A stall in the operating system's serial stack still costs blocks, + and the drop counter in the sidecar remains the check on that. diff --git a/docs/adr/043-a1-protocol-failure-policy.md b/docs/adr/043-a1-protocol-failure-policy.md new file mode 100644 index 0000000..09ad83d --- /dev/null +++ b/docs/adr/043-a1-protocol-failure-policy.md @@ -0,0 +1,56 @@ +# ADR 043 — An A1 protocol run stops on a failed recording, and refuses a survey it cannot write + +- Date: 2026-09-07 +- Status: Accepted in source; bench qualification pending + +## Context + +A 297-point survey recorded 38 points and skipped over 250. Every skip carried the +same host rejection: the camera could not be opened before a transport timeout. +The run kept going to the end of the file, so a bench hour produced almost no data +and the operator learned the outcome only from the closing summary. + +A second run stopped at point 12 for an unrelated reason: the photodiode sampled a +direct path with no lamp-off dark reference, so A1 could not write a quantitative +sidecar. That gate is static — it depends on the placement and its dark provenance, +not on the drive — and it fails identically for every point in the file. The run +start checked the photodiode's connection, lease, freshness and sample rate, but +not this. + +## Decision + +Treat the two failure classes differently. + +Every failed recording is repeated on the same point after 1, 2 and 4 s. The +recording coordinator is idle by then, so a repeat cannot collide with a recording +the host still holds, and each attempt writes its own timestamped files and sidecar, +so a partial attempt stays beside the one that worked instead of being overwritten. + +A point whose retries are used up is lost, named with the owner's own reason, and +the survey goes on. Three lost points in a row end the run: at that scale it is the +bench that failed, not the point, and the rest of the file would be empty as well. +Every way a point can be lost — a refused drive retarget, a failed recording — +counts towards the same streak. + +Check the placement's dark provenance at the button press. A photodiode on the +camera or emission path with no dark reference refuses the whole protocol, in the +photodiode owner's own words, and names the open-loop way out. The estimator's +other gates need the drive to be running and stay per-point. + +A restoration that stays unconfirmed keeps the camera-configuration ownership and +the failure on screen, and Stop retries it. The drive lease is released while the +run waits, so a failed camera does not hold the modulation owner as well. + +## Consequences + +A transient failure — a refused camera start, a dropped trigger marker, a photodiode +that missed one window — costs seconds instead of a measurement point, and a +persistent one costs four points instead of a bench day. A survey that cannot produce +a quantitative sidecar at all never starts. + +The cost is that a measurement folder can hold the files of an attempt that failed +next to the ones that worked. The per-attempt sidecar names which files belong +together, and the run's closing summary names every lost point with its reason. + +Failure wording no longer counts camera retries when there were none; the reason the +run stopped is the reason it reports. diff --git a/docs/adr/044-a1-states-the-controller-mode.md b/docs/adr/044-a1-states-the-controller-mode.md new file mode 100644 index 0000000..d768cbc --- /dev/null +++ b/docs/adr/044-a1-states-the-controller-mode.md @@ -0,0 +1,61 @@ +# ADR 044 — An A1 run states the controller's experiment mode + +- Date: 2026-09-07 +- Status: Accepted in source; bench qualification pending + +## Context + +The photodiode measures `a` from marker-bounded windows. The markers are `Marker` +frames with `source=1` that the controller stamps on the photodiode stream at every +modulation phase 0 — and it stamps them only in `mode=A1`. In `mode=A2` the optical +comparator drives the camera trigger instead and writes `source=2` markers. + +`PrepareA1` existed in the contract and in the modulation owner, but no plugin ever +sent it. A1 therefore ran in whatever mode the controller happened to be in. After +any A2 work the bench answered an A1 survey with many camera triggers (comparator +edges) and no phase-0 markers at all, so every point refused its quantitative +sidecar. A live `STATUS` on 2026-09-07 read `mode=A2 trigger_source=PD_COMPARATOR +cmp_armed=1 stream_marker_drops=9306`, which is exactly that state. + +The command was also unsendable. `CONFIG` accepts `mode`, `rate_hz`, +`block_samples`, `raw` and `summary`, and answers anything else with +`SYNTAX unknown_config_field`; `a1_config_command` added `wave`, `freq_mhz`, +`center_dac` and `amplitude_dac`. + +## Decision + +A1 asks the modulation owner for `mode=A1` when its protocol run takes the lease, +and only when the owner's published `controller_mode` is not already `A1`. `CONFIG` +is refused while the acquisition runs and `STOP` ends the photodiode stream, so a +mode change costs a stream restart — a run that needs no change must not pay for it. + +`PrepareA1` carries nothing. `CONFIG` sets the acquisition rate, block size and +output flags along with the mode, and those belong to the controller's owner: it +reads them back from `STATUS`, restates them unchanged, and refuses the command +while it has not seen them. The rate in `CONFIG` is the controller's portable-sampler +rate, which is *not* the rate the photodiode streams at — the DMA path samples at its +own fixed rate and stamps that into the frames. A1 deriving the one from the other +refused every run on a 500 kSa/s bench. + +The sequence is `STOP` → `CONFIG mode=A1 …` → `START` when the acquisition was +running, so the stream comes back the way it was found. A refusal ends the run: +without A1 mode there is nothing to measure against. + +## Consequences + +An A1 survey no longer inherits an A2 session's trigger policy, and a controller that +refuses the mode says so instead of producing points without `a`. + +The A1 sidecar loses `center_dac` and `amplitude_dac`, and the modulation target +loses `a1_configuration`. All three read a payload that no run ever populated, so +they were absent from every artifact ever written. + +A run that finds the controller in A2 restarts the photodiode stream once, before +its first recording. The photodiode treats that as a new segment, which it already +does after every rate change. + +A2's own path is untouched and keeps two known divergences from the firmware: +`validate_a2_configuration` accepts sample rates up to 500 kSa/s where the controller +stops at 100 kSa/s and does not bound `block_samples` at all, and `a2_config_command` +sends a fixed `rate_hz=20000` rather than the configured rate. Aligning those changes +what A2 protocols are allowed to ask for and needs its own decision. diff --git a/docs/adr/045-a-refused-a-names-which-gate-holds-it.md b/docs/adr/045-a-refused-a-names-which-gate-holds-it.md new file mode 100644 index 0000000..9ad5361 --- /dev/null +++ b/docs/adr/045-a-refused-a-names-which-gate-holds-it.md @@ -0,0 +1,67 @@ +# ADR 045 — A refused `a` names which gate holds it + +- Date: 2026-09-07 +- Status: Accepted in source; bench qualification pending + +## Context + +The photodiode publishes `a` only from a window bounded by phase-0 markers that +covers two whole modulation cycles (ADR 033). Everything that leaves it without +such a window produced one sentence: + +``` +no stretch of samples covers two whole modulation cycles between triggers +(0 trigger(s) in the last 8894464 samples) — lower the frequency, or raise the +photodiode cache length +``` + +Four different benches reach it, and the advice fits one of them: + +- the controller is not in `mode=A1`, so no phase-0 marker is stamped at all + (ADR 044) — no cache length helps; +- markers arrive stamped on a sample index the ring never holds, i.e. the marker + clock and the sample clock disagree — no cache length helps; +- the stream keeps restarting, which clears the samples and their markers + together, so the window never grows — the device is dropping samples; +- the window really is shorter than two cycles of a slow drive — raise the cache + or lower `f`. + +An A1 survey loses *every* point to whichever one it is, one full-length +recording at a time, and the sidecar refusal is the whole report an unattended +run leaves behind. Naming the wrong lever costs a survey. + +## Decision + +`EstimateError::IncompleteModulationCycles` carries what tells the four apart: +the marker count, the retained window in seconds, how long ago the stream +restarted when that restart is recent enough to be why the window is short, and +how many markers were stamped outside the window. The `Display` renders one +sentence per bench, and only the fourth mentions the cache length. + +The counters are the photodiode owner's: it counts markers dropped for landing +before the ring, and remembers when the ring last restarted. The estimator +renders; it does not measure. + +A1 latches the refusal reason on the same tick and for the same reason it +latches the optical summary (ADR 034): read after both finalizes, the reason +describes the bench after the recording, and a finalize that restarts the stream +reports a 0.2 s window whatever the real gate was. + +A1 renders `Controller: mode=…` whenever the modulation owner publishes a mode +other than `A1`, and stays silent otherwise. The refusal tells the operator to +check the mode, and until now the contract carried `controller_mode` without any +panel rendering it. + +## Consequences + +Seconds replace sample counts in the refusal: `8894464 samples` was a number an +operator had to divide by a stream rate that is not shown anywhere. + +A retained window that is short because the ring is still filling after a +restart is no longer reported as a frequency or cache problem. The restart +branch wins over the others, because a restart also explains a zero marker +count. + +Marker frames stamped before the ring were dropped silently. They are counted +now, which is the only evidence that separates a trigger that is absent from one +whose clock disagrees. diff --git a/docs/adr/046-stage-a-command-completion-and-record-preservation.md b/docs/adr/046-stage-a-command-completion-and-record-preservation.md new file mode 100644 index 0000000..add84fc --- /dev/null +++ b/docs/adr/046-stage-a-command-completion-and-record-preservation.md @@ -0,0 +1,90 @@ +# ADR 046: Confirm controller commands and preserve acquisition records + +Date: 2026-09-07 +Status: Accepted + +## Problem + +A1 preparation and drive updates shared a replaceable pending slot with UI slider +updates. A later update could remove an unsent mode change. Three A1 retarget +services returned Applied when queued, before the controller accepted them. A1 +also treated an InProgress service reply as completion, and did not supply the +revision required by PrepareA1. After A2, the controller could remain stopped +although its mode was already A1 (the A2 drive-sync configuration uses A1 mode). + +A missing live optical estimate prevented A1 from writing its config sidecar. +Conversely, a sidecar I/O failure could leave recording_completed_ok true. +Neither behavior describes acquisition correctly. Live analysis must not decide +whether acquisition metadata is retained. + +## Decision + +- Keep slider coalescing. Put automation operations in a separate bounded FIFO. +- Report InProgress until every command receives its controller response. Retain + bounded terminal responses by requester and request ID so later requests do not + overwrite a completion that its caller has not consumed. +- A1 polls the same request identity for completion and passes device refusals to + its runner. It waits for preparation before issuing point commands, then waits + for all point commands before settling and recording. +- PrepareA1 always sends STOP, CONFIG, START and STATUS. Its readback must + confirm A1, RUNNING, J24_PHASE0 and comparator off. The reported mode alone is + not sufficient: the A2 drive-synchronized capture configures A1 mode at its own + sample rate, and the firmware releases an armed comparator only when a CONFIG + leaves A2. Clear the previous A2 target metadata. +- A1 asks for preparation when the controller reports a different mode, a stopped + acquisition, or an A2 target that the owner still carries. One preparation per + protocol run, so a correct A1 acquisition keeps its stream. +- Explicit safe-off and lease expiry clear queued automation and do not re-arm + the operator's previous output during lease cleanup. +- Write initial A1/A2 point metadata before camera start. Write final metadata + even when the live optical estimate is absent. A1 records acquisition_complete, + scientific_status=requires_offline_review and optical_unavailable. A missing + estimate is not replaced by a commanded value in a measured-depth field. +- A1 records requested points, point outcomes, failures and final status in an + append-only *_progress.jsonl file in the output folder. A write failure stops + the protocol through normal cleanup instead of continuing without records. +- A fixed A1 protocol does not qualify its next frequency using the previous + frequency's optical window. The sample-density check remains and waits briefly + for readback after acquisition restart. Feedback depth locks retain their gates. +- Missing PDQ phase markers require controller/stream diagnosis. The camera + trigger cable does not generate these internal markers. A short retained window + needs more complete periods, not a lower modulation frequency. + +This supersedes the sidecar-refusal behavior described in ADR 034 and ADR 045. +It does not weaken RAW/PDQ integrity checks or qualify any physical result. + +## Validation and delivery + +Regression tests cover command bursts, retained failures, delayed completion, +repeated A2 drive-sync/A1 preparation, metadata I/O failure and durable skipped +point records. The build workflow runs Stage-A tests on Windows as well as the +other build platforms before packaging plugins. + +The laboratory target is Windows. A macOS release build is a source/build check, +not a Windows delivery or a bench pass. Install the matching Windows bundle only +after its tests and build pass. Close Augur fully before replacing all four +Stage-A plugin folders under `%USERPROFILE%\.augur\plugins`; retain BUILD-INFO.txt +with the session. The production Teensy image is `teensy41`, not the standalone +comparator bring-up image. + +Before a long session, run short A1 -> A2 -> A1 captures on the actual Windows PC. +Check finalized RAW/PDQ and sidecars, PDQ sample and marker continuity, expected +camera trigger edges and the saved quality diagnostics. Repeat a few points to +exercise file finalization and reacquisition. No local mock test proves USB, +camera timing, signal quality or the installed firmware version. + +## Recording-root and workflow completion, 2026-09-08 + +The host's default recording folder and the photodiode folder can differ. Add the +optional host `StartRecording.root_dir` contract (augur-rs ADR 028) and have A1/A2 +pass their resolved workflow root explicitly. A2 freezes it, verifies opened-file +parents, and records actual paths before stimulus start. It does not move large +files between volumes at the end of each point. Older host behavior is detected +before PD acquisition. A1 retains its existing file-gather fallback. + +A2 now preserves a user measurement id across uniquely named runs, presents timed +remaining duration, and journals acquisition progress independently of scientific +qualification. Atomic point JSON replacement preserves previous metadata if an +update fails. Retained modulation responses are queried with the original request +identity; host preview resets cannot discard an active run. These changes keep +the A1/A2 controls consistent without moving experiment logic into the host. diff --git a/docs/architecture.md b/docs/architecture.md index a46c4c1..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: @@ -68,6 +74,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/README.md b/docs/features/README.md index 7e97865..18a7ff6 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -4,15 +4,20 @@ Repository-level feature notes for larger plugin suites, interface migrations, a ## Available Briefs +- [Stage-A command completion and record preservation](../adr/046-stage-a-command-completion-and-record-preservation.md) — confirmed commands, common recording roots, consistent A1/A2 controls, durable metadata and Windows validation. + - [Stage-A Bench Stack](./stage-a.md) — Teensy-driven Stage-A bench: two serial ports, two minimal plugins, and the shared `stage-a-io` library. -- [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). +- [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. -- [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). +- [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. +- [CI Prebuilt Plugin Bundles](./ci-prebuilt-plugin-bundles.md) — every pull request and every push to `main` builds all runtime plugins for macOS (arm64/x86_64), Linux and Windows, staged in the exact `~/.augur/plugins/` layout so a bench machine installs by copying instead of compiling. `main` publishes them as a rolling `plugins-latest` release that needs no GitHub login, each bundle carrying a `BUILD-INFO.txt` with the `augur-rs` revision and `rustc` version it was built against. CI calls the repo's own build/install scripts rather than restating the install layout in YAML, and `rust-toolchain.toml` now pins the host's `1.95.0` because plugins are dlopened into the host process (ADR 030). - [Plugin Authoring Docs Refresh](./plugin-authoring-doc-refresh.md) — repo docs synced to the current runtime-only interface, host views, and `GlobalSettings`. - [Plugin Install And Reload](./plugin-install-reload.md) — macOS dylib identity fix so installed plugins do not keep pointing back at Cargo's build tree during reloads. - [Investigation Workspace Alignment](./investigation-workspace-alignment.md) — in-tree plugins updated for stable ids, linked 2D/3D/table datasets, and candidate-stage accepted/rejected event inspection. @@ -22,4 +27,16 @@ Repository-level feature notes for larger plugin suites, interface migrations, a - [Clickable 2D Overlays via Marker `source_row`](./clickable-overlays-source-row.md) — plugin-api ABI 4 `source_dataset_id`/`source_row_id` plumbing and failed-fit click-to-select loop. - [Action Requests And Single-Cluster Refit](./action-requests-and-refit.md) — plugin-declared host actions, eveSMLM refit/commit/discard flow on the `augur.evesmlm.refit_preview` dataset. - [Reconstruction Workflow](./reconstruction.md) — accumulated localization tables rendered and exported by the host. -- [eveSMLM Pipeline](./evesmlm.md) — candidate finding, fitting, and post-processing as three chainable plugins. +- [eveSMLM Pipeline](./evesmlm.md) — candidate finding, fitting, and post-processing as three chainable plugins, chained through the shared `evesmlm-types` contract crate rather than through each other: every plugin exports `augur_plugin_vtable`, so a plugin-to-plugin rlib dependency duplicated that symbol and failed to link on Linux and Windows while macOS accepted it (ADR 031). + +A2 production capture modes and synchronization evidence: [ADR 041](../adr/041-stage-a-a2-drive-sync-capture.md). + +Photodiode evidence files are written off the stream reader thread: [ADR 042](../adr/042-photodiode-recording-writes-off-the-reader-thread.md). + +An A1 protocol retries a rejected camera start, stops on a failed recording and refuses a survey whose sidecars it could not write: [ADR 043](../adr/043-a1-protocol-failure-policy.md). + +An A1 run states the controller's experiment mode instead of inheriting it: [ADR 044](../adr/044-a1-states-the-controller-mode.md). + +A withheld photodiode `a` names which of the four gates holds it, and A1 renders a controller left outside `mode=A1`: [ADR 045](../adr/045-a-refused-a-names-which-gate-holds-it.md). + +- [Windows lab watcher](stage-a-lab-watch.md): independent RAW/PDQ progress warnings to an iPhone through ntfy. diff --git a/docs/features/ci-prebuilt-plugin-bundles.md b/docs/features/ci-prebuilt-plugin-bundles.md new file mode 100644 index 0000000..bdacf01 --- /dev/null +++ b/docs/features/ci-prebuilt-plugin-bundles.md @@ -0,0 +1,165 @@ +# CI Prebuilt Plugin Bundles + +**Status:** built +**Workflow:** [`.github/workflows/build-plugins.yml`](../../.github/workflows/build-plugins.yml) +**ADR:** [030 — Prebuilt plugin bundles are produced by CI](../adr/030-prebuilt-plugin-bundles-from-ci.md) + +## Problem + +Installing a plugin used to require a Rust toolchain, a sibling `augur-rs` +checkout, and a working `cargo`. That is a reasonable ask of a contributor and an +unreasonable ask of the bench machine that actually runs the experiment. A +measurement PC should not need a development environment just to pick up a fixed +plugin. + +## What it does + +Every pull request and every push to `main` builds all runtime plugins on four +platforms and stages them in the exact layout `~/.augur/plugins/` expects: + +```text +augur-plugins-macos-arm64/ + BUILD-INFO.txt + stage-a-a1/ + plugin.toml + libaugur_plugin_stage_a_a1.dylib + protocols/ + example.csv + example.toml + stage-a-modulation/ + stage-a-photodiode/ + localization/ + … +``` + +Installing is then a copy — no build step, no toolchain. + +| Bundle | Runner | Library | +|---|---|---| +| `macos-arm64` | `macos-latest` | `.dylib` | +| `macos-x86_64` | `macos-13` | `.dylib` | +| `linux-x86_64` | `ubuntu-latest` | `.so` | +| `windows-x86_64` | `windows-latest` | `.dll` | + +Pull requests publish the bundles as workflow artifacts. Pushes to `main` +additionally publish a rolling GitHub Release tagged `plugins-latest`, one zip per +platform plus `SHA256SUMS.txt`. The release exists because artifacts require a +GitHub login and expire; a release asset can be fetched from the bench with +`curl` and no account. + +`workflow_dispatch` takes an `augur_rs_ref` input for building a bundle against a +different host branch or tag — but note that GitHub only offers `workflow_dispatch` +for workflows present on the default branch, so until this lands on `main` the +`AUGUR_RS_REF` default below is the only way to retarget the host revision. + +## Why it is shaped this way + +**Two sibling checkouts, not one.** The workspace depends on the host by path +(`augur-core = { path = "../augur-rs/augur-core" }`), so the job checks +`augur-plugins` and `augur-rs` out next to each other under the workspace root +and builds from the former. A single-repo checkout cannot resolve the dependency +at all. + +**`augur-rs/.git` is deleted right after checkout.** `build-runtime-plugins.sh` +adds `--config patch."…augur-rs.git"…` flags whenever it finds a sibling +`augur-rs` *git checkout*. With path dependencies that patch matches nothing — +cargo reports `Patch … was not used in the crate graph` and exits 0 — but it +still costs a git fetch of the checkout. Removing `.git` makes the script's +detection fail, and the path dependencies are used directly. + +**The toolchain is pinned and read from the file.** Plugins are `cdylib`s the +host `dlopen`s into its own process, so they must be built by the same compiler +as `augur-gui`. [`rust-toolchain.toml`](../../rust-toolchain.toml) pins the same +`1.95.0` as `augur-rs`, and the workflow parses the channel out of that file +rather than repeating the version — CI cannot drift from the pin. + +**Linux system dependencies are the plugins' own, not the host's.** The job +installs `pkg-config` and `libudev-dev`, which is what `serialport` (used by +`stage-a-modulation` and `stage-a-photodiode`) needs. Reusing +`augur-rs/.github/scripts/install-linux-deps.sh` was tried first and reverted: it +pulls the whole GUI stack that no plugin links, and it does not exist on every +`augur-rs` revision this job can be pointed at, so the Linux build failed on the +value of `augur_rs_ref` rather than on anything in this repository. + +**Warnings are not errors here.** `actions-rust-lang/setup-rust-toolchain` +injects `RUSTFLAGS="-D warnings"` by default. This job ships artifacts, so it +sets `rustflags: ""` — a dead-code warning in one plugin must not deny the bench +a bundle for all of them. Lint gating belongs in its own job. + +**The build goes through the repo's own two scripts.** `build-runtime-plugins.sh` +and `install-built-plugins.sh` already know which crates are runtime plugins, +which library name each `plugin.toml` declares, that A1's `protocols/` folder has +to travel with the plugin, and that macOS copies need their dylib id rewritten to +`@loader_path/`. Re-implementing any of that in YAML would be a second +source of truth. CI runs the same commands a developer runs, only with +`--dest dist/`. + +**Archiving happens once, in the release job.** The build matrix uploads raw +folders; the Ubuntu release job zips them. `zip` is not available in the Windows +runner's bash by default, so packaging on each runner would have needed a +per-platform branch for no benefit. + +## Provenance + +Each bundle carries `BUILD-INFO.txt`: + +```text +bundle: macos-arm64 +built_at: 2026-08-04T19:38:11Z +augur_plugins: 30e677c… +augur_rs_ref: main +augur_rs_sha: d43652a… +rustc: rustc 1.95.0 (…) +``` + +That is what turns an "ABI mismatch" report from the bench into an answerable +question: it records exactly which host revision and which compiler the installed +library was built against. + +## Installing a bundle + +1. Download the archive for the platform from the + [`plugins-latest` release](https://github.com/muthmann/augur-plugins/releases/tag/plugins-latest) +2. Unpack it +3. Copy the plugin folders inside into `~/.augur/plugins/` +4. In `augur-gui`: **Plugins** → **Scan for New Plugins** → enable + +See [Installing Runtime Plugins](../installing-plugins.md) for the full +installed layout and troubleshooting. + +## Which host revision the bundles are built against + +`AUGUR_RS_REF` currently defaults to the `augur-rs` branch +`fix/gui-layout-and-alignment` (open host PR #36), **not** to `main`. + +That is not a preference, it is the state of the two repositories. These plugins +reference `TableSchema` fields (`layer_id`, `semantic_label`, `provenance`, +`column_display`, `row_id_column`, `time_column`, `coordinate_space_3d`), +`HostViewKind::Scatter3dFromTable`, `HostDatasetDescriptor.relations` / +`.display` and `HostViewRegistry.actions` — none of which exist on `augur-rs` +`main`, which still has a two-field `TableSchema`. Defaulting to `main` would be +a guaranteed red build that never hands the bench a bundle. + +The first CI run is what surfaced this: the repository as checked in cannot be +built by anyone who does not already have an unmerged host branch on disk. + +**Move the default back to `main` in the same commit that the host API lands +there.** Until then, `BUILD-INFO.txt` is the thing that keeps this honest — it +records the exact host ref and SHA behind every library, so an installed plugin +can always be traced to the host revision it matches. + +## Limitations + +- The macOS bundles are single-architecture, not universal. `augur-gui` ships as + a universal binary, so an Intel Mac needs `macos-x86_64` and an Apple Silicon + Mac needs `macos-arm64`; picking the wrong one fails at load, not at copy. +- `macos-13` is GitHub's last x86_64 macOS runner image. When it is retired, the + Intel bundle needs a cross-build (`--target x86_64-apple-darwin`), which the + install script does not currently look for — it only reads `target/`. +- The bundles are unsigned. macOS Gatekeeper does not quarantine libraries loaded + by `dlopen` from a user directory, so this has not needed handling, but a + downloaded archive may still need `xattr -d com.apple.quarantine` if Safari + attached the flag. +- `Cargo.lock` is gitignored, so builds are not `--locked`. A dependency + publishing a broken semver-compatible release can turn CI red without a commit + in either repository. diff --git a/docs/features/evesmlm.md b/docs/features/evesmlm.md index 8ef6cb6..f65d73f 100644 --- a/docs/features/evesmlm.md +++ b/docs/features/evesmlm.md @@ -8,6 +8,21 @@ The eveSMLM pipeline is implemented as three focused plugins so each stage can b 2. **EVE Candidate Fitting** (`DerivedData`) converts each completed candidate into one or more sub-pixel localization estimates, republishes `EveLocalizationResults` and `LocalizationResults`, and exposes both the shared host-view dataset `augur.evesmlm.current_localizations` and the rejected-fit dataset `augur.evesmlm.rejected_fits`. 3. **EVE Post-Processing** (`DerivedData`) filters, drift-corrects, and evaluates the fitted localizations, then republishes the same host-view dataset id and view ids with the same schema and metadata. +## Shared Contract Crate + +The three plugins do **not** depend on each other. Everything that crosses a +stage boundary — `EveEvent`, `EveCluster`, `EveCandidates`, `EveLocalization`, +`EveLocalizationResults`, `FitMethod`, the `CTX_*` channel names, and the +`augur.evesmlm.current_localizations` dataset/registry builders that both +fitting and post-processing publish — lives in the `evesmlm-types` crate. + +That is not a stylistic choice. Each plugin `cdylib` exports +`augur_plugin_vtable`, so a plugin that linked another plugin's rlib pulled the +symbol in twice. macOS linked it anyway; `rust-lld` and MSVC's `link.exe` +refused, which meant the chain silently only worked on macOS until CI first +built the repository on Linux and Windows (ADR 031). Each plugin still +re-exports the names it used to own, so existing `use` paths keep working. + ## Why Three Plugins - Keeps raw-event grouping separate from numerical fitting, so candidate quality can be inspected directly. diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md index 38e8800..ba09722 100644 --- a/docs/features/stage-a-a1.md +++ b/docs/features/stage-a-a1.md @@ -28,7 +28,22 @@ [ADR 027](../adr/027-stage-a-a1-declarative-protocols.md) (surveys are run from a file, and `I_k` becomes a sweepable axis), [ADR 028](../adr/028-stage-a-sensor-readout-travels-with-the-measurement.md) - (the sensor readout travels with the measurement, column-wise) + (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), + [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), + [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 @@ -45,8 +60,14 @@ 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 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 @@ -89,18 +110,28 @@ selected**. is the default and the source of record. It is also **fail-closed**: the photodiode publishes no `a` unless it can prove its estimator window covers whole modulation cycles, which it does from the firmware phase-0 **marker -frames** on its own stream port. If those markers never arrive — no trigger, or -a firmware build that does not stamp them — it refuses forever, with a reason -that reads like a settings problem: +frames** on its own stream port. If those markers never arrive — the controller +outside `mode=A1`, an unplugged phase-0 cable, or a firmware build that does not +stamp them — it refuses forever. The refusal names which of the four benches it +is, because only one of them is the window length (ADR 045): ``` -No stretch of samples covers two whole modulation cycles between triggers -(0 trigger(s) in the last 3446784 samples) — lower the frequency, or raise the -photodiode cache length +No phase-0 trigger has arrived in the last 17.8 s — the controller stamps one +per modulation cycle only in mode=A1, and after A2 work the comparator drives +the trigger instead: check the modulation plugin's mode, that the drive is +armed and running, and the phase-0 cable ``` -Zero markers in millions of samples is a missing marker stream, not a short -window, and no setting fixes it. **Depth `a` source** is the way past: +The sidecar refusal quotes the reason that held **while the recording ran**, not +the one read after the finalizes: the photodiode's optical section is latched +during the recording (ADR 034) and its refusal now with it. + +The panel renders `Controller: mode=…` whenever the controller is not in A1, so +that check has an answer without a serial terminal. A protocol run asks for A1 +mode itself (ADR 044); manual work on the panel does not. + +When no bench change brings the markers back, **Depth `a` source** is the way +past: | Setting | `a` is | Needs | Verified against the light | |---|---|---|---| @@ -198,29 +229,53 @@ succeed. ### Protocol — a survey from a file (ADR 027) +Starting a protocol inspects the selected output folder and measurement ID first. +A point is reused only when its canonical `_config.toml` explicitly records +`acquisition_complete = true`, no acquisition failure, the same exact protocol +SHA-256, and its original one-based row and total. All four referenced artifacts +(RAW, camera TOML, PDQ, and PD sidecar) must be nonempty regular files in that +measurement folder. Copied Windows paths resolve by basename inside this folder; +external files and symlinks do not qualify. Older metadata without explicit +completion, malformed metadata, and incomplete recordings remain pending. + +Completed rows are skipped, including rows after gaps. Repeated identical rows +remain distinct. The original protocol and row numbers stay unchanged. Status +shows reused, newly recorded, and missing points; remaining time excludes reused +points. If every point is complete, starting issues no hardware commands. Missing +or unreadable measurement-folder listings stop the scan, except that a folder +which does not yet exist starts a new acquisition. Acquisition completion is not +scientific acceptance: reused data still require offline review. + + The four sweep buttons each move one axis and leave the others wherever they are. That is right for exploring and wrong for a survey: `I_k` could not be swept at all, and what a block recorded lived in the panel rather than in anything that travels with the results. A **protocol** is a file naming every axis for every recording. The reader is -chosen by extension, and both produce the same flat list of points. +chosen by extension, and both produce the same flat list of points. Both +tolerate what a spreadsheet writes: CRLF line endings, and the UTF-8 +byte-order mark Excel's "CSV UTF-8" prepends — unstripped, the BOM becomes +part of the first header cell and the file is refused for missing a column it +visibly has. **CSV — one row per recording**, and the one to reach for: it opens in a 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. @@ -235,9 +290,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" @@ -245,11 +305,37 @@ 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. 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) + +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 @@ -262,14 +348,67 @@ its three. its predecessor would be recorded under parameters the file does not name. - **The file's `duration_s` wins** over the panel's, or the survey would not be reproducible from the protocol alone. -- **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. +- **The controller's mode is stated, not inherited**: when the modulation owner + reports a controller that is not in `A1`, the run asks for the mode before its + first point, so the firmware stamps the phase-0 markers the photodiode + measures `a` from. Without it a survey that followed A2 work ran against the + optical comparator and produced no `a` at all. A controller already in A1 is + left alone — the change would restart the photodiode stream — see ADR 044. +- **Validated up front**: ranges, bounds, the `MAX_POINTS = 4096` product limit, + the same whole-cycle window check the ladder makes against its lowest + frequency, and — for a measured depth — the placement's dark provenance: a + photodiode on the camera or emission path with no lamp-off dark reference + fails every quantitative sidecar in the file, so the survey is refused rather + than its twelfth point. All on the button press, before the drive moves. The point count + 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 - summary. + summary. A failed *recording* is first repeated on the same point after 1, 2 and + 4 s; only then is the point lost. Three lost points in a row end the run — a + survey that skipped on every failure ran to the end of the file with almost no + data, and one that stopped at the first stumble threw away the rest. The + refusal names the artifact that was missing, not just "not every file was + finalized" — see ADR 043. + +### 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. @@ -287,6 +426,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 @@ -297,6 +437,20 @@ 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. + +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, 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 the pilot, `a≈0` for the background) in the modulation plugin, then press the matching button — the recording captures whatever `a` is currently set. @@ -308,7 +462,7 @@ drive the operator already armed (frequency, normalized cycle mean `ū`, and calibration stay untouched). The modulation owner accepts this command only with an applied measured calibration and `OPTICAL_LOG_SINE`; manual, constant, DAC-sine, square, and optical-linear modes are rejected. It renews the lease per -point, waits for a fresh, marker-bounded photodiode `a` from a confirmed `I_tot` +point *and* on a heartbeat between points, waits for a fresh, marker-bounded photodiode `a` from a confirmed `I_tot` anchor to settle, hands the point to the normal recording coordinator, and releases the lease at the end or on abort. Sweep points require `min a > 0` — record `a≈0` with the background button instead. Sidecars @@ -319,6 +473,19 @@ last sweep amplitude until the operator's own `depth a` setting is re-applied event-count point re-applies its locked depth under the lease instead of trusting the drive to still be where a previous action left it (ADR 013). +**Leases are kept alive against the deadline the owner granted, not the one A1 +asked for** (ADR 029). Both owners cap the TTL they hand out — a client that +dies must not hold the laser — so the whole-run TTL a sweep, a ladder or a +protocol asks for is *not* what it gets. A1 reads the real +`expires_at_unix_ms` off the owner's own snapshot and renews on a heartbeat once +less than 20 s of the granted window is left. Without it, any point longer than +the cap outlived its lease mid-recording and the owner did what an expired lease +must do — `STOP`, output off — which then read as three separate faults at once: +`the modulation owner requires an active automation lease`, `cannot write a +quantitative A1 sidecar without a fresh photodiode optical summary`, and a +`Camera: … no trigger signal` line that looked exactly like an unplugged +`EXT_TRIGGER` cable but was the drive being off. + **Naming.** Files share an `_[_role]` stem under an `/` subfolder (`_pilot` / `_background` tag the reference runs, `_ec_fHz` an event-count point): @@ -350,10 +517,31 @@ the photodiode Data directory no longer have to be kept aligned by hand: schedules, so a row-per-poll table is padding by construction. Bias codes are dropped: the camera's own bias sidecar already carries them. Nothing is resampled or aligned, failed polls are kept as `faults`, and the whole path is - best-effort — a source with no monitoring block simply produces no file - (ADR 028). - -**A1 config sidecar** captures: `measurement_id`, file + best-effort (ADR 028). + + **The companion CSV only exists if the host is asked for it.** It is governed + by the host's own **Record sensor monitoring** checkbox in the recording + panel, which A1 cannot set and cannot query — so no telemetry file means no + `.sensor.json`, whatever the camera supports. That switch used to reset to off + on every app start, which is how a survey could record forty runs and keep the + bench conditions of none of them; it is now persisted across restarts + (augur-rs). A1 reports it either way: when a finished run wrote no readout, + the panel names the switch rather than leaving the absence silent. The + 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 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 @@ -519,6 +707,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 @@ -548,3 +742,9 @@ Two cover the nested sweep (ADR 023): the whole 2 × 3 block records every depth at every frequency in depth order, on exactly **one** lease acquisition, never entering the search phase and finishing with `2/2 frequencies × 3 depths`; and a nested point's file stem carries both axes (`…_f50Hz_p03`). + +## Reliable capture and metadata (2026-09-07) + +A1 waits for controller completion before recording a protocol point. A stopped ADC is restarted during preparation, and so is one that A2 left running: the drive-synchronized A2 capture also configures A1 mode, at its own sample rate, so the mode string alone does not prove the acquisition is an A1 one. Missing live optical estimates are stored as optical_unavailable; they do not suppress the config file or turn a saved acquisition into a skipped point. Acquisition completion remains separate from scientific validity. Initial metadata is written before camera start. The output root also contains an append-only *_progress.jsonl log with point requests, outcomes and failure reasons. + +See [ADR 046](../adr/046-stage-a-command-completion-and-record-preservation.md) for the contract and Windows bench verification. diff --git a/docs/features/stage-a-a2.md b/docs/features/stage-a-a2.md new file mode 100644 index 0000000..2fa6f64 --- /dev/null +++ b/docs/features/stage-a-a2.md @@ -0,0 +1,216 @@ +# Stage-A A2 latency automation + +A2 records synchronized camera RAW and photodiode PDQ at calibrated optical step +points. It leases the existing modulation and photodiode owners and uses the host +camera recorder. Latency, measured contrast, optical t50 and timing uncertainty are +estimated offline. A completed capture is not proof of a qualified latency result. + +## Production capture + +Use `plugins/stage-a-a2/protocols/a2_drive_sync_smoke.toml` first, then +`a2_production_drive_sync.toml`. They explicitly select: + +```toml +timing_reference = "drive_sync" +trigger_validation = "offline_review" +``` + +The 43-point production schedule takes 7,337 seconds (122 min 17 s), including +settling and excluding operator pauses, device acknowledgements and finalization. +It has dark brackets, a blocked-drive reference, three cadence controls, seven +repeated reference rows, and two passes through 3 flux targets × 5 depths. Each +condition receives 200 commanded transitions per polarity across the two passes; +partial boundary cycles must be removed offline. The 3-point smoke takes 37 seconds +plus pauses and file operations. The older comparator templates remain available +for qualified comparator experiments and keep their original strict defaults. + +For a time-limited first block, `a2_core_drive_sync.toml` records 19 rows in +1,195 s (19 min 55 s) plus operator/file overhead. It keeps three flux targets, +three depths (0.28/0.45/0.80), dark/sham/cadence controls and four short reference +rows. Each grid condition has 50 commanded transitions per polarity before startup +and boundary exclusions. It is a prioritized first dataset, not the same precision +or drift replication as the full two-pass matrix. Keep the full file as an optional +extension; do not claim A2 scientific closure solely from the shortened capture. +A [separate Windows watcher](stage-a-lab-watch.md) can report missing file progress +and saved A2 errors. It does not qualify the data or protect against PC/network loss. + +The camera trigger cable must receive **J24 digital phase-zero sync** for +`drive_sync`. Do not leave it on the comparator output. Firmware confirms +`J24_PHASE0`, an unarmed comparator and `SQUARE` before capture. J24 emits a narrow +pulse once per period. Its falling edge ends that pulse; it is **not** the optical +OFF transition. The PD stream records phase-zero markers (`source=1`) in its sample +index space. Comparator captures instead use `source=2`, with explicit level, +sample index and device microsecond tick. Wire framing remains PDA1-compatible. + +The pulse is recorded as a digital marker alongside the PD samples; it is not +added electrically to the analog photodiode voltage. Both camera and PD must +observe corresponding timing anchors. Their device clocks remain independent. +A quiet interval precedes capture, and the stimulus is restarted only after both +recorders are open. The first new pulse train gives an identifiable common onset; +exclude the optical startup cycles from repeated-step estimates. +Offline analysis must match pulse sequences, reject gaps, unwrap device ticks and +fit offset and drift over each uninterrupted segment before mapping the measured +PD t50 to camera time. Nominal half-period is only a search window for optical OFF. +Never label the sync pulse's falling edge as optical OFF or align all rows by wall +clock/edge ordinal alone. + +## Acquisition contract + +The panel accepts a protocol and a measurement ID. It uses the PD owner's data +folder and archives the exact TOML by SHA-256. Camera RAW, camera configuration, +PDQ and both acquisition sidecars must remain together under `/`. +The host receives this explicit root; A2 checks the returned paths before continuing. + +Applied optical lobe/calibration, PD placement (`emission_path`), splitter fraction +(0.5), gain/load and selected reference ID come from the owners (ADR 040). No manual +copy of those values into TOML is required. A2 uses the current host camera state, +applies and confirms it before recording, then requests restoration on every exit. +EXT_TRIGGER and sensor telemetry are enabled; STC, Trail and ERC are disabled. + +`drive_sync` skips comparator calibration and optical amplitude gates. No 20 mV +minimum, peak-to-peak noise limit or measured dead-time gate prevents its capture. +Unrepresentable DAC/frequency settings, unavailable owners, failed leases, missing +or empty recordings, sample loss/discontinuity, short PD coverage and failed file +writes remain acquisition errors. These conditions can destroy the requested data. + +A2 `mean_u` is the **geometric** step pedestal: low/high targets are +`mean_u * exp(-a/2)` and `mean_u * exp(a/2)`. The production TOML uses +`mean_u = target_cycle_mean / cosh(a/2)`, rounded to 0.001, to match A1's nominal +cycle means 0.15, 0.30 and 0.45. Depths are 0.15, 0.28, 0.45, 0.80 and 1.30. +A1's sixth depth 1.70 is intentionally not part of this A2 matrix. Large depths are +model checks, not the small-step approximation. Matching commanded lobe means is +not evidence of matching camera-port flux: use the concurrent emission PD. + +`comparator` remains the backwards-compatible timing default. Each auto-threshold +point measures eight separate PD windows per plateau after a sample-clock settling +guard. It checks clipping, stream identity, spread/uncertainty of window means, +threshold-DAC range and representability. Raw peak-to-peak noise becomes a recorded +warning rather than a 20 mV criterion. These are threshold-placement diagnostics, +not proof that an individual noisy edge has a precise t50. The threshold is measured +again at each point, so an earlier reference is not silently reused after drift. +The ADC 3.3 V reference and threshold DAC 2.5 V reference remain separate. + +## Recording, evidence and failures + +A2 prepares the stimulus before recording and keeps the free-running 500 kSa/s DMA +PD stream. It never issues command-port `START` inside PDQ capture: that command +switches the ADC sampler and resets acquisition counters. `CONFIG rate_hz=20000` +is the supported portable sampler setting; it does not reduce the independent +DMA stream rate. Dark uses safe-off; point end stops the waveform explicitly. +`STOP` alone stops the command sampler and is not a stimulus-off command. + +Lease renewals have separate reply kinds and cannot advance acquisition phases. +Replies must match request, owner and run. Timeouts and cleanup retries are bounded. +PD sample progression has a five-second watchdog. Finalized receipts must attest +nonempty, contiguous, clean data of the requested duration. Failures retain paths, +hashes and the first cause; sidecar failures and unconfirmed cleanup are visible. +An aborted or failed point cannot become a successful completed protocol. + +A2 sidecar schema 2 includes timing policy, actual command values, firmware marker +loss counters at command boundaries, PDQ marker counts, raw paths/hashes and +cleanup evidence. Photodiode final receipts carry additive optional marker counts; +old owners decode but missing evidence needs review. Live preview trigger counts +and event-load peaks are best-effort diagnostics, not an authoritative RAW audit. +`offline_review` retains these warnings and continues; it never converts them into +a valid timing claim. `strict` stops on timing warnings. Hard acquisition errors +stop either policy. Offline H4/H5 review is required even if acquisition checks pass. + +## Firmware and scientific limits + +Production `stage-a-controller` firmware now uses the DMA write cursor for the +stream marker index (`STATUS marker_clock=dma_cursor_v1`). Foreground delays no +longer enter its index calculation. Skipped DMA blocks advance physical sample time +and increment loss evidence instead of compressing the time axis. Ambiguous cursor +snapshots are counted as marker losses. Interrupt latency, ADC conversion/pipeline, +2 µs sample quantization and the actual camera trigger path still need calibration. +This implementation is not a hardware measurement of their error bounds. + +The firmware build to flash for normal operation is the `teensy41` build. The +`a2_comparator` build is a standalone diagnostic and cannot run this capture path. +A new host plugin alone does not update the Teensy. Verify the firmware marker-clock +status, install matching runtime bundles, and fully restart the host before smoke. + +Noise references can characterize offset, noise and electrical crosstalk. A separate +reference cannot reconstruct the random fluctuations in a later optical edge. +With approximately 380 mV peak-to-peak fluctuations, 20 mV modulation is not by +itself evidence of identifiable single-cycle t50. Preserve raw PD samples. Estimate +mean edges over repeated cycles and report the residual per-cycle timing uncertainty; +do not subtract reference noise as if it were the same noise realization. +Intrinsic pixel jitter and absolute latency require bounded synchronization/input +uncertainty. Otherwise report fluorescence-chain latency or drive-relative response. + +See [ADR 041](../adr/041-stage-a-a2-drive-sync-capture.md) for the compatibility decision. + +## Controller completion and initial records (2026-09-07) + +A2 uses the shared queued controller service and retains its existing completion, integrity and cleanup checks. Initial point metadata is now written before camera start. A write failure ends preparation through normal cleanup. Return to A1 restores running ADC acquisition even when A2 drive-sync left the controller in A1 mode but stopped. + +See [ADR 046](../adr/046-stage-a-command-completion-and-record-preservation.md) for the contract and Windows bench verification. + +## Consistent recording workflow (2026-09-08) + +A2 now follows the A1 interaction pattern: Measurement id, New id, Protocol, +Run protocol, Continue and Stop. A blank id is generated at start. A supplied id +names the subfolder and is retained for subsequent runs. Each run adds its own +timestamp to point filenames and a separate progress journal, so repeating a +protocol under one measurement id does not overwrite the earlier run. +Ids accept up to 100 ASCII letters/digits, hyphens and underscores; Windows device +names are refused before hardware commands. Measurement settings cannot change +while a run is active. Continue is offered only at a manual pause. + +The photodiode's chosen data folder is resolved once to an absolute root. Camera +StartRecording and PD BeginRecording receive that same root. A2 verifies the +actual camera/PD parent directories before starting the stimulus and stores the +opened paths immediately. A host that ignores the root is stopped before PD +acquisition instead of silently splitting the files. The required generic host +change is documented in augur-rs ADR 028. **Install a matching host and plugin +build; this fix cannot be deployed by replacing the A2 DLL alone.** A1 also passes +its selected root and retains its historical post-finalization collection fallback. + +Loading a valid protocol shows its acquisition-plus-settling duration. During a +run the status shows point count, completed recordings, output folder and remaining +timed duration. The current recording/settle timer counts down. Manual pause time, +controller acknowledgement/auto-comparator qualification and disk finalization +are not predicted; they are explicitly additional time. Zero remaining timed +seconds during cleanup does not mean the files have finished closing. + +Modulation requests that report InProgress are polled with the same request id and +revision, at most five times per second, against the owner's retained completion +history. The original deadline is retained. A status response for another run is +ignored. A host preview reset does not erase an active acquisition. Explicit camera +start rejection does not send StopRecording for a different recording. + +Each invocation writes `_progress.jsonl` in its measurement folder. +It retains run start, point start/final result, run completion, paths and failure +or cleanup details. Point JSON is replaced atomically after syncing a temporary +file, so a failed update preserves the last complete version. The evidence field +`acquisition_complete` is separate from `valid` and offline scientific status: +preview timing warnings do not turn an intact offline capture into a failed file. +Hardware/recording failures remain visible and stop the run through cleanup. + +Tests cover full dark/step completion, root disagreement, changed owner settings, +metadata preservation, repeated ids, asynchronous completion identity, reset, +manual pauses, timing countdown and cleanup evidence. Windows USB/camera timing +still requires a short saved RAW/PDQ pair on the laboratory computer. + +## Automatic continuation + +Run with the same measurement ID and unchanged protocol to resume. A2 checks the +existing folder before acquiring hardware. It skips only original row numbers with +matching protocol SHA-256, matching point data, explicit `acquisition_complete` +and all four nonempty local artifacts (RAW, camera TOML, PDQ, PD JSON). Repeated +conditions remain separate protocol rows. Missing, partial or legacy records without +explicit completion are acquired again; existing files are kept with unique attempt +names. Offline timing warnings do not by themselves require a repeat. + +The display counts reused and newly acquired rows and estimates the remaining +acquisition and settling time. A completed protocol makes no hardware requests. +On resume, confirm the optical path for the first missing point; pauses crossed by +skipped rows are retained at the next acquired point. This prevents a saved dark or +blocked-drive reference from silently leaving the next measurement in the wrong +physical state. Resume verifies file presence, not a full offline integrity analysis. + +The UI mirror does not own the active run. Continue and Stop therefore stay +accessible in the panel; the live worker accepts Continue only at a manual pause +and treats Stop with no active run as a no-op. An early Continue click is consumed +and cannot acknowledge a later pause. 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/docs/features/stage-a-lab-watch.md b/docs/features/stage-a-lab-watch.md new file mode 100644 index 0000000..9d5c1af --- /dev/null +++ b/docs/features/stage-a-lab-watch.md @@ -0,0 +1,76 @@ +# Windows lab watcher and iPhone notifications + +## Scope + +`Laborwache.exe` is a separate, read-only process. It does not change Augur, its +configuration, a recording, a serial port or a laser output. It sends generic +status text through ntfy.sh; it does not upload recordings, file paths, sample +names or full error messages. + +It observes the selected RAW and PD recording folders every five seconds. Each +stream is checked independently. Sixty seconds without increasing file size sends +one warning for that stream. File rotation counts as progress; touching a file's +timestamp does not. Resumed progress sends one recovery message. A2 JSON sidecars +supply an additional warning when a finalized row reports an acquisition failure, +unconfirmed cleanup or an offline-review condition. Old sidecars present when the +watcher starts do not produce alerts. + +This is a **file-progress alarm**, not a hardware-health or scientific-validity +certificate. It cannot reliably distinguish an operator pause, a completed protocol +and a hung application from file inactivity alone. It does not announce A1/A2 +completion as proven, and it does not check trigger correspondence or PD signal +quality. For A1 it observes file progress only. A2 detailed errors appear once a +sidecar is written; otherwise the inactivity warning is the fallback. + +## Start on the Windows lab computer + +1. Download the newly built `augur-plugins-windows-x86_64` artifact from the approved + GitHub build. Install its plugin folders into `%USERPROFILE%\.augur\plugins` + with Augur closed. Keep the separate `lab-watch` folder outside the plugin folder. +2. On the iPhone, install **ntfy**, allow notifications, and permit them in the Focus + mode used during the experiment. Keep the default server `https://ntfy.sh`. +3. Open `lab-watch/Laborwache.exe`. Subscribe in ntfy to the exact random topic shown + by the program. The topic is saved locally for reuse. It is unguessable by design + but not a password-protected topic; do not share it. Anyone who knows it can read + or publish there. Messages therefore contain no research data. +4. Press Enter to send a test. Verify reception with the phone locked and on mobile + data before confirming `ja`. Server acceptance alone is not delivery proof. +5. Choose the current PD output folder and the current RAW output folder. They can + be the same folder. Select narrow session/day folders rather than an entire disk. +6. Start the watcher **before** the acquisition, then start the protocol. Leave its + console open. Ctrl+C ends only the watcher. A missing-data alarm after a physical + pause is expected: it asks you to inspect Augur; it does not stop a valid run. +7. Before leaving the desk, deliberately leave a small test capture idle long enough + to receive the inactivity warning, then verify a recovery message when both files + grow again. Do this as a recording test, not by disrupting a valuable acquisition. + +## Failure boundaries + +Network calls time out after eight seconds and retry pending messages after 30 s. +The watcher keeps scanning independently of Augur; a send failure never blocks the +recorders. The polling interval, scan time and a pending network request add latency +to the 60 s threshold. The console explicitly reports an unavailable warning channel. +An unsent stall message is replaced by recovery if the data resumes before delivery. + +A computer power failure, Windows sleep, closed watcher, full network outage or +failure of the notification service can prevent every local warning. This package +has **no external missed-heartbeat service**. Do not call it protection against +those failures or a reason to leave an optically unsafe setup unattended. Use short +blocks and local laboratory rules; an independently hosted heartbeat receiver is a +separate requirement for full remote outage detection. + +The topic path is stored under `%LOCALAPPDATA%\AugurLabWatch\topic.txt`. There is no +remote start/stop function. The watcher does not install itself as a service or +change Windows power, firewall or global script-execution settings. + +## Validation and build + +Run `python scripts/test_stage_a_watch.py` to test file progression, independent +streams, file rotation, alert deduplication, invalid/incomplete sidecars, unreachable +folders and notification retry behaviour. The tests never send a real push message. +GitHub packages the Windows executable with Python 3.12 and PyInstaller 6.16.0. The +Windows package and iPhone delivery still need their own build/receipt verification; +passing local Python tests does not establish either. + +Primary service documentation: [ntfy phone subscriptions](https://docs.ntfy.sh/subscribe/phone/) +and [publishing messages](https://docs.ntfy.sh/publish/). diff --git a/docs/features/stage-a-modulation.md b/docs/features/stage-a-modulation.md index d605647..da3a2bd 100644 --- a/docs/features/stage-a-modulation.md +++ b/docs/features/stage-a-modulation.md @@ -60,6 +60,17 @@ Every accepted setting change is transferred to the Teensy **immediately** as on no Apply button, no experiment state machine. The panel shows the modulation and live DAC code the board *reports* (`MOD` reply + 2 Hz `STATUS` poll), not merely the commanded values. +## Port discovery + +`auto` opens every candidate port and keeps the one that answers `HELLO` — the probe, not the port +name, tells the command port from the photodiode stream port of the same dual-serial device. Which +ports are candidates is platform-specific and shared with the photodiode plugin through +`stage-a-io::transport::candidate_ports()`: `cu.usbmodem*` on macOS (the callout node only, since +every device is listed twice), `ttyACM*` on Linux, and every USB-classified `COMn` on Windows, +where the name carries no device information at all (ADR 032). The settings picker lists exactly +the same set with each port's USB label. When nothing qualifies, the error names the ports the OS +did enumerate. + ## Contract - Owns the Teensy **command port** exclusively (one owner per port, ADR 006). The photodiode @@ -81,6 +92,10 @@ board *reports* (`MOD` reply + 2 Hz `STATUS` poll), not merely the commanded val longer have to infer these from DAC endpoints. `v_peak_dac` replaced the earlier `v_pi_dac`, and carries the absolute peak code rather than the span (ADR 016, ADR 025). +- `ModulationStateV1.optical_lobe` publishes the applied measured calibration + independently of the currently armed mode and point. Protocol runners such as + A2 use this field to command their own `mean_u` and `depth_a`; the operator + does not prepare those points in the modulation UI. - `mock` port runs the firmware-faithful `MockController` in-process for hardware-free tests. - The workflow-owner service and `WaveformV1` automation path remain exact-waveform contracts and do not use the UI Drive method. @@ -120,3 +135,9 @@ board *reports* (`MOD` reply + 2 Hz `STATUS` poll), not merely the commanded val conditional settings blocks, method-resolved bands, manual optical-band inversion, hard-ceiling rejection, immediate mock transfer, board-code echo, square drive, and owner-service fail-safe behavior. + +## Automation command completion (2026-09-07) + +Automation commands use a bounded FIFO separate from the replaceable slider slot. Applied means the controller replied successfully, not that a command was queued. Completed replies remain queryable by requester and request ID. PrepareA1 always restates the A1 configuration (STOP, CONFIG, START, STATUS) and verifies running ADC acquisition and the phase-marker source, because A2's drive-synchronized capture also configures A1 mode. Safe-off clears queued work and does not restore an armed waveform during lease cleanup. + +See [ADR 046](../adr/046-stage-a-command-completion-and-record-preservation.md) for the contract and Windows bench verification. diff --git a/docs/features/stage-a-photodiode.md b/docs/features/stage-a-photodiode.md index 14eca52..70cced1 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. @@ -34,6 +35,29 @@ source (the camera `EXT_TRIGGER` belongs to A1's camera-clock analysis, not here ("phase-0 trigger") on the chart. - The **modulation frequency is derived from the marker spacing** (`f = rate / mean marker gap`) and shown in the status; the mock emits synthetic markers so the overlay works without hardware. +- **Show comparator markers** separately overlays A2 comparator state markers + (`source=2`). It does not mix them into the phase-0 timing ring. + +## Guided PD references + +The collapsed **Guided PD references** panel records reusable raw reference data +without adding analysis controls to the live readout. A reference-set ID groups +four named captures: 30 s electronics dark, 30 s blocked-drive crosstalk, 30 s +static optical signal, and 100 s optical edges. Each capture stops +automatically, uses exclusive file creation, and writes its type, planned +duration, physical placement, splitter fraction and PD load to the sidecar. +After a successful capture, the panel advances to the next step. The operator +can still select a previous step to repeat it with a new reference-set ID. + +The panel guides the physical condition but does not duplicate command-port +ownership. A1 or A2 sets modulated drive states. In particular, A2 owns the +optical-edge sequence and comparator configuration. This keeps comparator H4, +H5, threshold, hysteresis and `invert` out of the generic PD UI. The current +470 kOhm load is a recorded configuration value, not a software filter. + +These recordings support later offline noise models and trigger-time uncertainty +estimates. The plugin does not claim that background subtraction can recover a +crossing that the comparator never observed. ## Modes @@ -75,6 +99,23 @@ 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. +- **A refusal names its own gate** (ADR 045). Four benches have no whole-cycle + window and only one of them is the window length: a controller outside + `mode=A1` stamps no phase-0 marker at all, a marker clock that disagrees with + the sample clock stamps markers the ring can never hold, a stream that keeps + restarting clears the samples and their markers together, and a slow drive + really can outgrow the window. The refusal says which, in seconds of stream + rather than sample counts, and only the last one mentions the cache length. - 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 @@ -129,6 +170,13 @@ but a chart setting. - **Start recording** / **Stop recording** buttons tee every incoming sample frame to `pd_rec_.pdq`; stopping writes the JSON sidecar. Both buttons (and the snapshot) are disabled until a data directory is selected. +- The file is written on a thread of its own, fed by a bounded queue of 1024 + frames (~8 s of stream at 500 kSa/s). The reader thread owns the serial port + and the firmware buffers only two DMA blocks, so a reader that waits for + storage makes the device drop whole blocks and the `.pdq` lose its contiguous + sample range — which is what A1/A2 reject a point for. A queue that runs full + therefore fails the recording with a named `write_error` instead of waiting + (ADR 042). - All three are momentary buttons whose presses are forwarded from the UI mirror to the live worker as monotonic press counters (`PressLatch`, ADR 010) and act only on a press **edge**. The previous unguarded `save_snapshot` @@ -137,11 +185,22 @@ but a chart setting. always-false state to the worker, so it could never stay recording. The `record` boolean setting remains as a non-schema compatibility alias. +## Port discovery + +`auto` listens briefly on every candidate port and keeps the one streaming CRC-clean PDA1 sample +frames — the probe, not the port name, is what identifies the stream port. Which ports are +candidates is platform-specific and shared with the modulation plugin through +`stage-a-io::transport::candidate_ports()`: `cu.usbmodem*` on macOS (the callout node only, since +every device is listed twice), `ttyACM*` on Linux, and every USB-classified `COMn` on Windows, +where the name carries no device information at all (ADR 032). The settings picker lists exactly +the same set, so a port offered in the dropdown is one `auto` would also have probed. When nothing +qualifies, the error names the ports the OS did enumerate. + ## Contract - Owns the Teensy **stream port** exclusively (ADR 006); the port carries no commands, so the - plugin is read-only by construction. It reuses `stage-a-io` (`default-features = false`) only - for the PDA1 wire parser — no client, worker, or transport. + plugin is read-only by construction. It uses `stage-a-io` for the PDA1 wire parser and for port + discovery — no client, worker, or transport. - **Frame-independent**: connecting is a checkbox setting; the reader thread and all views work with no camera attached (the host only calls `process_frame()` while frames flow). - Garbage on the port resynchronises at the next CRC-clean frame; skipped bytes and CRC failures diff --git a/docs/installing-plugins.md b/docs/installing-plugins.md index 45a79e9..0b17073 100644 --- a/docs/installing-plugins.md +++ b/docs/installing-plugins.md @@ -21,6 +21,33 @@ On Linux the library ends in `.so`. On Windows it ends in `.dll`. Host-owned built-in tools are part of `augur-gui` and are not installed from this repository. +## Install Without A Toolchain (recommended for bench machines) + +CI builds every runtime plugin on each push to `main` and publishes them as a +rolling [`plugins-latest`](https://github.com/muthmann/augur-plugins/releases/tag/plugins-latest) +release, already in the layout above. Installing is then a copy: + +```bash +curl -LO https://github.com/muthmann/augur-plugins/releases/download/plugins-latest/augur-plugins-macos-arm64.zip +unzip augur-plugins-macos-arm64.zip -d bundle +mkdir -p ~/.augur/plugins +cp -R bundle/*/ ~/.augur/plugins/ +``` + +Archives exist for `macos-arm64`, `macos-x86_64`, `linux-x86_64` and +`windows-x86_64`. The macOS libraries are per-architecture, not universal, so an +Apple Silicon machine needs `macos-arm64` even though `augur-gui` itself ships +universal — the wrong one fails at load time, not at copy time. + +Every archive carries a `BUILD-INFO.txt` naming the `augur-rs` revision and the +`rustc` version it was built with. That is the first thing to check against a +[plugin ABI mismatch](#plugin-abi-mismatch). Verify downloads against +`SHA256SUMS.txt` from the same release. + +The sections below cover building from source, which contributors still need. +See [CI Prebuilt Plugin Bundles](./features/ci-prebuilt-plugin-bundles.md) for how +the bundles are produced. + ## Build One Plugin ```bash @@ -99,6 +126,14 @@ The library was built against an older plugin interface or does not export the r The installed runtime library is stale relative to the host ABI. +If the library came from a release bundle, compare its `BUILD-INFO.txt` against the +running host first — `augur_rs_sha` says which host revision it was built for, and +`rustc` says which compiler produced it. Plugins are loaded into the host process, +so a compiler mismatch is as much a cause as a stale revision; +[`rust-toolchain.toml`](../rust-toolchain.toml) pins the same version `augur-rs` +does, but only a rustup-managed `cargo` honours it. Check with +`cargo --version` — a Homebrew or distro `cargo` earlier on `PATH` ignores the pin. + 1. Rebuild the plugin against the current sibling `augur-rs` checkout. 2. Replace the installed runtime library in `~/.augur/plugins//`. 3. On macOS, prefer `./scripts/install-built-plugins.sh --profile release` or rewrite the copied dylib id with `install_name_tool -id "@loader_path/" ...`. diff --git a/evesmlm-types/Cargo.toml b/evesmlm-types/Cargo.toml new file mode 100644 index 0000000..ffab69b --- /dev/null +++ b/evesmlm-types/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "evesmlm-types" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Shared eveSMLM contract types and current-localization dataset builders for the candidate/fitting/post-processing plugin chain" + +[dependencies] +augur-plugin-api.workspace = true +augur-plugin-types.workspace = true +serde.workspace = true diff --git a/plugins/evesmlm-candidates/src/types.rs b/evesmlm-types/src/candidates.rs similarity index 87% rename from plugins/evesmlm-candidates/src/types.rs rename to evesmlm-types/src/candidates.rs index 6bf8b1b..2320657 100644 --- a/plugins/evesmlm-candidates/src/types.rs +++ b/evesmlm-types/src/candidates.rs @@ -2,6 +2,7 @@ use augur_plugin_api::FfiCdEvent; use serde::{Deserialize, Serialize}; pub const CTX_EVE_CANDIDATES: &str = "augur.evesmlm.candidates"; +pub const ACCEPTED_CANDIDATE_EVENTS_DATASET_ID: &str = "augur.evesmlm.candidates.accepted_events"; fn default_cluster_complete() -> bool { true @@ -17,6 +18,22 @@ pub enum CandidateFindingMethod { } impl CandidateFindingMethod { + pub fn from_index(index: usize) -> Self { + match index { + 1 => Self::Eigenfeature, + 2 => Self::FrameBased, + _ => Self::Dbscan, + } + } + + pub fn index(self) -> usize { + match self { + Self::Dbscan => 0, + Self::Eigenfeature => 1, + Self::FrameBased => 2, + } + } + pub fn label(self) -> &'static str { match self { Self::Dbscan => "DBSCAN", @@ -124,17 +141,3 @@ pub struct EveCandidates { pub n_events_processed: usize, pub finding_method: CandidateFindingMethod, } - -#[derive(Debug, Clone)] -pub(crate) struct TrackedCluster { - pub id: u64, - pub centroid_x: f64, - pub centroid_y: f64, - pub event_count: usize, - pub last_seen_frame: u64, - pub last_grown_frame: u64, - pub frames_since_growth: usize, - pub complete: bool, - pub emitted: bool, - pub cluster: EveCluster, -} diff --git a/evesmlm-types/src/datasets.rs b/evesmlm-types/src/datasets.rs new file mode 100644 index 0000000..fe3d43f --- /dev/null +++ b/evesmlm-types/src/datasets.rs @@ -0,0 +1,488 @@ +//! The current-localization dataset, its schema and the host-view registry +//! built from it, plus the conversion to the standard `LocalizationResults`. +//! +//! These live here rather than in the fitting plugin because post-processing +//! republishes the same dataset — see the crate docs for why a plugin must not +//! link another plugin's rlib. + +use augur_plugin_api::{ + HostDatasetDescriptor, HostDatasetDisplayMetadata, HostDatasetKind, HostDatasetRelation, + HostMarkerShape, HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, + TableColumn, TableColumnData, TableColumnDisplayEntry, TableColumnDisplayFormat, + TableColumnDisplayMetadata, TableColumnValues, TableCoordinateSpace2d, TableCoordinateSpace3d, + TableDatasetV1, TableRowProvenance, TableSchema, TableValueType, +}; +use augur_plugin_types::{Localization, LocalizationResults}; + +use crate::candidates::ACCEPTED_CANDIDATE_EVENTS_DATASET_ID; +use crate::localization::{EveLocalization, EveLocalizationResults}; + +pub const CURRENT_LOCALIZATIONS_DATASET_ID: &str = "augur.evesmlm.current_localizations"; +pub const CURRENT_LOCALIZATIONS_LAYER_ID: &str = "augur.layer.evesmlm.current_localizations"; +pub const CURRENT_LOCALIZATIONS_VIEW_ID: &str = "augur.evesmlm.current_localizations.compact"; +pub const CURRENT_LOCALIZATIONS_3D_VIEW_ID: &str = "augur.evesmlm.current_localizations.scatter3d"; + +pub fn current_localizations_registry() -> HostViewRegistry { + current_localizations_registry_for_results(&EveLocalizationResults::default(), None) +} + +pub fn current_localizations_registry_for_results( + results: &EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, +) -> HostViewRegistry { + HostViewRegistry { + datasets: vec![HostDatasetDescriptor { + id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), + title: "Current EVE localizations".into(), + kind: HostDatasetKind::TableV1(current_localizations_schema_for_results( + results, + sensor_dims, + )), + empty_message: "No EVE localizations in the current frame.".into(), + display: Some(HostDatasetDisplayMetadata { + layer_title: Some("Current EVE localizations".into()), + default_visibility: Some(true), + default_color: Some([90, 170, 255, 255]), + default_marker_shape: Some(HostMarkerShape::Cross), + default_size: Some(6.0), + }), + relations: vec![HostDatasetRelation { + target_dataset_id: ACCEPTED_CANDIDATE_EVENTS_DATASET_ID.into(), + via_column: "cluster_id".into(), + target_column: "cluster_id".into(), + }], + }], + views: vec![ + HostViewDescriptor { + id: CURRENT_LOCALIZATIONS_VIEW_ID.into(), + title: "Current Localizations".into(), + dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: CURRENT_LOCALIZATIONS_3D_VIEW_ID.into(), + title: "Current Localizations 3D".into(), + dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::Scatter3dFromTable { + x_column: "x_px".into(), + y_column: "y_px".into(), + z_column: "timestamp_us".into(), + }, + }, + ], + actions: Vec::new(), + } +} + +pub fn current_localizations_schema() -> TableSchema { + current_localizations_schema_for_results(&EveLocalizationResults::default(), None) +} + +pub fn current_localizations_schema_for_results( + results: &EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, +) -> TableSchema { + TableSchema { + columns: vec![ + TableColumn { + id: "row_id".into(), + title: "ID".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "cluster_id".into(), + title: "Cluster".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "timestamp_us".into(), + title: "Timestamp (us)".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "span_start_us".into(), + title: "Span Start (us)".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "span_end_us".into(), + title: "Span End (us)".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "x_px".into(), + title: "X (px)".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "y_px".into(), + title: "Y (px)".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "sigma_x_px".into(), + title: "Sigma X (px)".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "sigma_y_px".into(), + title: "Sigma Y (px)".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "n_events".into(), + title: "Events".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "polarity_balance".into(), + title: "Polarity balance".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "fit_residual".into(), + title: "Fit residual".into(), + value_type: TableValueType::F64, + }, + TableColumn { + id: "fit_method".into(), + title: "Fit method".into(), + value_type: TableValueType::String, + }, + ], + coordinate_space_2d: current_localizations_2d_space(results, sensor_dims), + coordinate_space_3d: current_localizations_3d_space(results, sensor_dims), + row_id_column: Some("row_id".into()), + time_column: Some("timestamp_us".into()), + layer_id: Some(CURRENT_LOCALIZATIONS_LAYER_ID.into()), + semantic_label: Some("localizations".into()), + provenance: Some(TableRowProvenance { + anchor_time_column: Some("timestamp_us".into()), + span_start_column: Some("span_start_us".into()), + span_end_column: Some("span_end_us".into()), + anchor_frame_column: None, + }), + column_display: vec![ + TableColumnDisplayEntry { + column_id: "row_id".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Identifier), + hide_in_compact: true, + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "cluster_id".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Identifier), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "timestamp_us".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::TimestampMicros), + label: Some("Time".into()), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "span_start_us".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::TimestampMicros), + label: Some("Span start".into()), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "span_end_us".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::TimestampMicros), + label: Some("Span end".into()), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "x_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "y_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "sigma_x_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 2 }), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "sigma_y_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 2 }), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "fit_residual".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 3 }), + ..Default::default() + }, + }, + TableColumnDisplayEntry { + column_id: "fit_method".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Category), + ..Default::default() + }, + }, + ], + } +} + +pub fn current_localizations_dataset(results: &EveLocalizationResults) -> TableDatasetV1 { + TableDatasetV1::new(vec![ + TableColumnData { + column_id: "row_id".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(localization_row_id) + .collect(), + ), + }, + TableColumnData { + column_id: "cluster_id".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(|value| value.cluster_id) + .collect(), + ), + }, + TableColumnData { + column_id: "timestamp_us".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(|value| value.timestamp_us) + .collect(), + ), + }, + TableColumnData { + column_id: "span_start_us".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(|value| value.span_start_us) + .collect(), + ), + }, + TableColumnData { + column_id: "span_end_us".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(|value| value.span_end_us) + .collect(), + ), + }, + TableColumnData { + column_id: "x_px".into(), + values: TableColumnValues::F64( + results.localizations.iter().map(|value| value.x).collect(), + ), + }, + TableColumnData { + column_id: "y_px".into(), + values: TableColumnValues::F64( + results.localizations.iter().map(|value| value.y).collect(), + ), + }, + TableColumnData { + column_id: "sigma_x_px".into(), + values: TableColumnValues::F64( + results + .localizations + .iter() + .map(|value| value.sigma_x) + .collect(), + ), + }, + TableColumnData { + column_id: "sigma_y_px".into(), + values: TableColumnValues::F64( + results + .localizations + .iter() + .map(|value| value.sigma_y) + .collect(), + ), + }, + TableColumnData { + column_id: "n_events".into(), + values: TableColumnValues::U64( + results + .localizations + .iter() + .map(|value| value.n_events as u64) + .collect(), + ), + }, + TableColumnData { + column_id: "polarity_balance".into(), + values: TableColumnValues::F64( + results + .localizations + .iter() + .map(|value| value.polarity_balance) + .collect(), + ), + }, + TableColumnData { + column_id: "fit_residual".into(), + values: TableColumnValues::F64( + results + .localizations + .iter() + .map(|value| value.fit_residual) + .collect(), + ), + }, + TableColumnData { + column_id: "fit_method".into(), + values: TableColumnValues::String( + results + .localizations + .iter() + .map(|value| value.fit_method.label().to_owned()) + .collect(), + ), + }, + ]) + .expect("current localization columns should stay aligned") +} + +fn current_localizations_2d_space( + results: &EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, +) -> Option { + sensor_dims + .map(|(width, height)| (0.0, f64::from(width), 0.0, f64::from(height))) + .or_else(|| localization_xy_bounds(results)) + .map(|(x_min, x_max, y_min, y_max)| TableCoordinateSpace2d { + x_column: "x_px".into(), + y_column: "y_px".into(), + x_min, + x_max, + y_min, + y_max, + }) +} + +fn current_localizations_3d_space( + results: &EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, +) -> Option { + let (x_min, x_max, y_min, y_max) = sensor_dims + .map(|(width, height)| (0.0, f64::from(width), 0.0, f64::from(height))) + .or_else(|| localization_xy_bounds(results))?; + let (z_min, z_max) = localization_time_bounds(results)?; + Some(TableCoordinateSpace3d { + x_column: "x_px".into(), + y_column: "y_px".into(), + z_column: "timestamp_us".into(), + x_min, + x_max, + y_min, + y_max, + z_min, + z_max, + }) +} +pub fn localization_xy_bounds(results: &EveLocalizationResults) -> Option<(f64, f64, f64, f64)> { + let mut localizations = results.localizations.iter(); + let first = localizations.next()?; + let mut x_min = first.x; + let mut x_max = first.x; + let mut y_min = first.y; + let mut y_max = first.y; + for localization in localizations { + x_min = x_min.min(localization.x); + x_max = x_max.max(localization.x); + y_min = y_min.min(localization.y); + y_max = y_max.max(localization.y); + } + Some((x_min, x_max.max(x_min), y_min, y_max.max(y_min))) +} + +pub fn localization_time_bounds(results: &EveLocalizationResults) -> Option<(f64, f64)> { + if let Some(first) = results.localizations.first() { + let mut min_time = first.timestamp_us; + let mut max_time = first.timestamp_us; + for localization in &results.localizations { + min_time = min_time.min(localization.timestamp_us); + max_time = max_time.max(localization.timestamp_us); + } + return Some((min_time as f64, max_time.max(min_time) as f64)); + } + + if results.frame_window_end_us >= results.frame_window_start_us { + return Some(( + results.frame_window_start_us as f64, + results.frame_window_end_us as f64, + )); + } + + None +} + +pub fn localization_row_id(localization: &EveLocalization) -> u64 { + localization.cluster_id.rotate_left(3) + ^ localization.timestamp_us + ^ localization.x.to_bits().rotate_left(7) + ^ localization.y.to_bits().rotate_left(19) + ^ localization.sigma_x.to_bits().rotate_left(31) + ^ localization.sigma_y.to_bits().rotate_left(43) + ^ localization.fit_residual.to_bits().rotate_left(53) + ^ (localization.n_events as u64).rotate_left(11) + ^ (localization.fit_method.index() as u64).rotate_left(59) + ^ localization.span_start_us.rotate_left(17) + ^ localization.span_end_us.rotate_left(29) +} + +pub fn to_localization_results(results: &EveLocalizationResults) -> LocalizationResults { + LocalizationResults { + localizations: results + .localizations + .iter() + .map(|localization| Localization { + x: localization.x, + y: localization.y, + sigma_x: localization.sigma_x, + sigma_y: localization.sigma_y, + amplitude: 0.0, + background: 0.0, + timestamp_us: localization.timestamp_us, + fit_error: localization.fit_residual, + }) + .collect(), + frame_window_start_us: results.frame_window_start_us, + frame_window_end_us: results.frame_window_end_us, + } +} diff --git a/evesmlm-types/src/lib.rs b/evesmlm-types/src/lib.rs new file mode 100644 index 0000000..00007be --- /dev/null +++ b/evesmlm-types/src/lib.rs @@ -0,0 +1,35 @@ +//! Shared eveSMLM contract. +//! +//! The candidate, fitting and post-processing plugins form a chain: fitting +//! consumes what candidates publishes, and post-processing consumes what +//! fitting publishes. Expressing that by having one plugin crate depend on +//! another looks natural, but plugin crates are `cdylib`s that each export +//! `augur_plugin_vtable` — and a plugin that links another plugin's rlib pulls +//! that symbol in twice. The Apple linker tolerates the duplicate; `rust-lld` +//! and MSVC's `link.exe` do not, so the chain built on macOS and failed to link +//! on Linux and Windows. +//! +//! Everything that crosses a plugin boundary therefore lives here, in a plain +//! library crate that exports no vtable. Plugins depend on this crate, never on +//! each other. + +pub mod candidates; +pub mod datasets; +pub mod localization; + +pub use candidates::{ + CandidateFindingMethod, ClusterBoundary, EveCandidates, EveCluster, EveEvent, + ACCEPTED_CANDIDATE_EVENTS_DATASET_ID, CTX_EVE_CANDIDATES, +}; +pub use datasets::{ + current_localizations_dataset, current_localizations_registry, + current_localizations_registry_for_results, current_localizations_schema, + current_localizations_schema_for_results, localization_row_id, localization_time_bounds, + localization_xy_bounds, to_localization_results, CURRENT_LOCALIZATIONS_3D_VIEW_ID, + CURRENT_LOCALIZATIONS_DATASET_ID, CURRENT_LOCALIZATIONS_LAYER_ID, + CURRENT_LOCALIZATIONS_VIEW_ID, +}; +pub use localization::{ + EveLocalization, EveLocalizationResults, FitMethod, RejectedFitRow, RejectionReason, + CTX_EVE_LOCALIZATION_RESULTS, +}; diff --git a/plugins/evesmlm-fitting/src/types.rs b/evesmlm-types/src/localization.rs similarity index 100% rename from plugins/evesmlm-fitting/src/types.rs rename to evesmlm-types/src/localization.rs diff --git a/plugins/evesmlm-candidates/Cargo.toml b/plugins/evesmlm-candidates/Cargo.toml index f8e7d66..07c2377 100644 --- a/plugins/evesmlm-candidates/Cargo.toml +++ b/plugins/evesmlm-candidates/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] augur-plugin-api.workspace = true +evesmlm-types.workspace = true nalgebra = "0.33" serde.workspace = true serde_json.workspace = true diff --git a/plugins/evesmlm-candidates/src/lib.rs b/plugins/evesmlm-candidates/src/lib.rs index d51776b..4556b2e 100644 --- a/plugins/evesmlm-candidates/src/lib.rs +++ b/plugins/evesmlm-candidates/src/lib.rs @@ -6,7 +6,7 @@ pub mod dbscan; pub mod eigenfeature; -pub mod types; +mod tracking; use std::collections::{HashMap, HashSet}; @@ -23,11 +23,11 @@ use augur_plugin_api::{ }; use serde_json::{json, Value}; -use types::TrackedCluster; -pub use types::{ +pub use evesmlm_types::{ CandidateFindingMethod, ClusterBoundary, EveCandidates, EveCluster, EveEvent, CTX_EVE_CANDIDATES, }; +use tracking::TrackedCluster; const KERNEL_G1: [f64; 5] = [1.0 / 16.0, 0.25, 3.0 / 8.0, 0.25, 1.0 / 16.0]; const KERNEL_G2: [f64; 9] = [ @@ -134,24 +134,6 @@ impl PolarityMode { } } -impl CandidateFindingMethod { - fn from_index(index: usize) -> Self { - match index { - 1 => Self::Eigenfeature, - 2 => Self::FrameBased, - _ => Self::Dbscan, - } - } - - fn index(self) -> usize { - match self { - Self::Dbscan => 0, - Self::Eigenfeature => 1, - Self::FrameBased => 2, - } - } -} - #[derive(Debug, Clone)] pub struct CandidateSettings { pub finding_method: CandidateFindingMethod, diff --git a/plugins/evesmlm-candidates/src/tracking.rs b/plugins/evesmlm-candidates/src/tracking.rs new file mode 100644 index 0000000..04fd140 --- /dev/null +++ b/plugins/evesmlm-candidates/src/tracking.rs @@ -0,0 +1,20 @@ +//! Internal cluster-tracking bookkeeping. +//! +//! Not part of the cross-plugin contract — the shared eveSMLM types live in +//! the `evesmlm-types` crate. + +use evesmlm_types::EveCluster; + +#[derive(Debug, Clone)] +pub(crate) struct TrackedCluster { + pub id: u64, + pub centroid_x: f64, + pub centroid_y: f64, + pub event_count: usize, + pub last_seen_frame: u64, + pub last_grown_frame: u64, + pub frames_since_growth: usize, + pub complete: bool, + pub emitted: bool, + pub cluster: EveCluster, +} diff --git a/plugins/evesmlm-fitting/Cargo.toml b/plugins/evesmlm-fitting/Cargo.toml index 4e3e8c1..1e14aaa 100644 --- a/plugins/evesmlm-fitting/Cargo.toml +++ b/plugins/evesmlm-fitting/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] augur-plugin-api.workspace = true augur-plugin-types.workspace = true -augur-plugin-evesmlm-candidates = { path = "../evesmlm-candidates" } +evesmlm-types.workspace = true levenberg-marquardt = "0.14" nalgebra = "0.33" num-complex = "0.4" diff --git a/plugins/evesmlm-fitting/src/gaussian.rs b/plugins/evesmlm-fitting/src/gaussian.rs index 429e8dd..848113d 100644 --- a/plugins/evesmlm-fitting/src/gaussian.rs +++ b/plugins/evesmlm-fitting/src/gaussian.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_candidates::EveCluster; +use evesmlm_types::EveCluster; use crate::{mean_xy, FitEstimate}; diff --git a/plugins/evesmlm-fitting/src/lib.rs b/plugins/evesmlm-fitting/src/lib.rs index 31b83b3..51103d9 100644 --- a/plugins/evesmlm-fitting/src/lib.rs +++ b/plugins/evesmlm-fitting/src/lib.rs @@ -10,7 +10,6 @@ pub mod log_gaussian; pub mod mean_xy; pub mod phasor; pub mod radial_symmetry; -pub mod types; use augur_plugin_api::{ export_plugin, AnalysisSeverity, EventStoreHandle, FfiColorRgba, FfiMarkerOverlayItem, @@ -26,22 +25,21 @@ use augur_plugin_api::{ TableColumnDisplayMetadata, TableColumnValues, TableCoordinateSpace2d, TableCoordinateSpace3d, TableDatasetV1, TableRowProvenance, TableSchema, TableValueType, }; -pub use augur_plugin_evesmlm_candidates::{ - EveCandidates, EveCluster, EveEvent, CTX_EVE_CANDIDATES, -}; use augur_plugin_types::{Localization, LocalizationResults, CTX_LOCALIZATION_RESULTS}; -use serde_json::{json, Value}; -pub use types::{ +pub use evesmlm_types::{ + current_localizations_dataset, current_localizations_registry, + current_localizations_registry_for_results, current_localizations_schema, + current_localizations_schema_for_results, localization_row_id, localization_time_bounds, + localization_xy_bounds, to_localization_results, EveCandidates, EveCluster, EveEvent, EveLocalization, EveLocalizationResults, FitMethod, RejectedFitRow, RejectionReason, - CTX_EVE_LOCALIZATION_RESULTS, + ACCEPTED_CANDIDATE_EVENTS_DATASET_ID, CTX_EVE_CANDIDATES, CTX_EVE_LOCALIZATION_RESULTS, + CURRENT_LOCALIZATIONS_3D_VIEW_ID, CURRENT_LOCALIZATIONS_DATASET_ID, + CURRENT_LOCALIZATIONS_LAYER_ID, CURRENT_LOCALIZATIONS_VIEW_ID, }; +use serde_json::{json, Value}; const OVERLAY_COLOR: [u8; 4] = [60, 220, 140, 220]; const CANDIDATE_DEPENDENCY: [&str; 1] = ["EVE Candidate Finding"]; -pub const CURRENT_LOCALIZATIONS_DATASET_ID: &str = "augur.evesmlm.current_localizations"; -pub const CURRENT_LOCALIZATIONS_LAYER_ID: &str = "augur.layer.evesmlm.current_localizations"; -pub const CURRENT_LOCALIZATIONS_VIEW_ID: &str = "augur.evesmlm.current_localizations.compact"; -pub const CURRENT_LOCALIZATIONS_3D_VIEW_ID: &str = "augur.evesmlm.current_localizations.scatter3d"; pub const REJECTED_FITS_DATASET_ID: &str = "augur.evesmlm.rejected_fits"; pub const REJECTED_FITS_LAYER_ID: &str = "augur.layer.evesmlm.rejected_fits"; pub const REJECTED_FITS_COMPACT_VIEW_ID: &str = "augur.evesmlm.rejected_fits.compact"; @@ -52,406 +50,10 @@ pub const REFIT_PREVIEW_DATASET_ID: &str = "augur.evesmlm.refit_preview"; pub const REFIT_PREVIEW_LAYER_ID: &str = "augur.layer.evesmlm.refit_preview"; pub const REFIT_PREVIEW_VIEW_ID: &str = "augur.evesmlm.refit_preview.compact"; -pub const ACCEPTED_CANDIDATE_EVENTS_DATASET_ID: &str = "augur.evesmlm.candidates.accepted_events"; - pub const ACTION_REFIT_CLUSTER: &str = "augur.evesmlm.refit_cluster"; pub const ACTION_COMMIT_REFIT: &str = "augur.evesmlm.commit_refit"; pub const ACTION_DISCARD_REFIT: &str = "augur.evesmlm.discard_refit"; -pub fn current_localizations_registry() -> HostViewRegistry { - current_localizations_registry_for_results(&EveLocalizationResults::default(), None) -} - -pub fn current_localizations_registry_for_results( - results: &EveLocalizationResults, - sensor_dims: Option<(u16, u16)>, -) -> HostViewRegistry { - HostViewRegistry { - datasets: vec![HostDatasetDescriptor { - id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), - title: "Current EVE localizations".into(), - kind: HostDatasetKind::TableV1(current_localizations_schema_for_results( - results, - sensor_dims, - )), - empty_message: "No EVE localizations in the current frame.".into(), - display: Some(HostDatasetDisplayMetadata { - layer_title: Some("Current EVE localizations".into()), - default_visibility: Some(true), - default_color: Some([90, 170, 255, 255]), - default_marker_shape: Some(HostMarkerShape::Cross), - default_size: Some(6.0), - }), - relations: vec![HostDatasetRelation { - target_dataset_id: ACCEPTED_CANDIDATE_EVENTS_DATASET_ID.into(), - via_column: "cluster_id".into(), - target_column: "cluster_id".into(), - }], - }], - views: vec![ - HostViewDescriptor { - id: CURRENT_LOCALIZATIONS_VIEW_ID.into(), - title: "Current Localizations".into(), - dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), - placement: HostViewPlacement::AnalysisPanel, - kind: HostViewKind::CompactTable, - }, - HostViewDescriptor { - id: CURRENT_LOCALIZATIONS_3D_VIEW_ID.into(), - title: "Current Localizations 3D".into(), - dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), - placement: HostViewPlacement::Window, - kind: HostViewKind::Scatter3dFromTable { - x_column: "x_px".into(), - y_column: "y_px".into(), - z_column: "timestamp_us".into(), - }, - }, - ], - actions: Vec::new(), - } -} - -pub fn current_localizations_schema() -> TableSchema { - current_localizations_schema_for_results(&EveLocalizationResults::default(), None) -} - -pub fn current_localizations_schema_for_results( - results: &EveLocalizationResults, - sensor_dims: Option<(u16, u16)>, -) -> TableSchema { - TableSchema { - columns: vec![ - TableColumn { - id: "row_id".into(), - title: "ID".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "cluster_id".into(), - title: "Cluster".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "timestamp_us".into(), - title: "Timestamp (us)".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "span_start_us".into(), - title: "Span Start (us)".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "span_end_us".into(), - title: "Span End (us)".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "x_px".into(), - title: "X (px)".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "y_px".into(), - title: "Y (px)".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "sigma_x_px".into(), - title: "Sigma X (px)".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "sigma_y_px".into(), - title: "Sigma Y (px)".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "n_events".into(), - title: "Events".into(), - value_type: TableValueType::U64, - }, - TableColumn { - id: "polarity_balance".into(), - title: "Polarity balance".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "fit_residual".into(), - title: "Fit residual".into(), - value_type: TableValueType::F64, - }, - TableColumn { - id: "fit_method".into(), - title: "Fit method".into(), - value_type: TableValueType::String, - }, - ], - coordinate_space_2d: current_localizations_2d_space(results, sensor_dims), - coordinate_space_3d: current_localizations_3d_space(results, sensor_dims), - row_id_column: Some("row_id".into()), - time_column: Some("timestamp_us".into()), - layer_id: Some(CURRENT_LOCALIZATIONS_LAYER_ID.into()), - semantic_label: Some("localizations".into()), - provenance: Some(TableRowProvenance { - anchor_time_column: Some("timestamp_us".into()), - span_start_column: Some("span_start_us".into()), - span_end_column: Some("span_end_us".into()), - anchor_frame_column: None, - }), - column_display: vec![ - TableColumnDisplayEntry { - column_id: "row_id".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::Identifier), - hide_in_compact: true, - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "cluster_id".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::Identifier), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "timestamp_us".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::TimestampMicros), - label: Some("Time".into()), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "span_start_us".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::TimestampMicros), - label: Some("Span start".into()), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "span_end_us".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::TimestampMicros), - label: Some("Span end".into()), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "x_px".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "y_px".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "sigma_x_px".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 2 }), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "sigma_y_px".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 2 }), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "fit_residual".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 3 }), - ..Default::default() - }, - }, - TableColumnDisplayEntry { - column_id: "fit_method".into(), - display: TableColumnDisplayMetadata { - format: Some(TableColumnDisplayFormat::Category), - ..Default::default() - }, - }, - ], - } -} - -pub fn current_localizations_dataset(results: &EveLocalizationResults) -> TableDatasetV1 { - TableDatasetV1::new(vec![ - TableColumnData { - column_id: "row_id".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(localization_row_id) - .collect(), - ), - }, - TableColumnData { - column_id: "cluster_id".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.cluster_id) - .collect(), - ), - }, - TableColumnData { - column_id: "timestamp_us".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.timestamp_us) - .collect(), - ), - }, - TableColumnData { - column_id: "span_start_us".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.span_start_us) - .collect(), - ), - }, - TableColumnData { - column_id: "span_end_us".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.span_end_us) - .collect(), - ), - }, - TableColumnData { - column_id: "x_px".into(), - values: TableColumnValues::F64( - results.localizations.iter().map(|value| value.x).collect(), - ), - }, - TableColumnData { - column_id: "y_px".into(), - values: TableColumnValues::F64( - results.localizations.iter().map(|value| value.y).collect(), - ), - }, - TableColumnData { - column_id: "sigma_x_px".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.sigma_x) - .collect(), - ), - }, - TableColumnData { - column_id: "sigma_y_px".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.sigma_y) - .collect(), - ), - }, - TableColumnData { - column_id: "n_events".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.n_events as u64) - .collect(), - ), - }, - TableColumnData { - column_id: "polarity_balance".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.polarity_balance) - .collect(), - ), - }, - TableColumnData { - column_id: "fit_residual".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.fit_residual) - .collect(), - ), - }, - TableColumnData { - column_id: "fit_method".into(), - values: TableColumnValues::String( - results - .localizations - .iter() - .map(|value| value.fit_method.label().to_owned()) - .collect(), - ), - }, - ]) - .expect("current localization columns should stay aligned") -} - -fn current_localizations_2d_space( - results: &EveLocalizationResults, - sensor_dims: Option<(u16, u16)>, -) -> Option { - sensor_dims - .map(|(width, height)| (0.0, f64::from(width), 0.0, f64::from(height))) - .or_else(|| localization_xy_bounds(results)) - .map(|(x_min, x_max, y_min, y_max)| TableCoordinateSpace2d { - x_column: "x_px".into(), - y_column: "y_px".into(), - x_min, - x_max, - y_min, - y_max, - }) -} - -fn current_localizations_3d_space( - results: &EveLocalizationResults, - sensor_dims: Option<(u16, u16)>, -) -> Option { - let (x_min, x_max, y_min, y_max) = sensor_dims - .map(|(width, height)| (0.0, f64::from(width), 0.0, f64::from(height))) - .or_else(|| localization_xy_bounds(results))?; - let (z_min, z_max) = localization_time_bounds(results)?; - Some(TableCoordinateSpace3d { - x_column: "x_px".into(), - y_column: "y_px".into(), - z_column: "timestamp_us".into(), - x_min, - x_max, - y_min, - y_max, - z_min, - z_max, - }) -} - pub fn refit_preview_registry_for_results( results: &EveLocalizationResults, sensor_dims: Option<(u16, u16)>, @@ -563,57 +165,6 @@ fn refit_action_param_schema() -> SettingsSchema { } } -fn localization_xy_bounds(results: &EveLocalizationResults) -> Option<(f64, f64, f64, f64)> { - let mut localizations = results.localizations.iter(); - let first = localizations.next()?; - let mut x_min = first.x; - let mut x_max = first.x; - let mut y_min = first.y; - let mut y_max = first.y; - for localization in localizations { - x_min = x_min.min(localization.x); - x_max = x_max.max(localization.x); - y_min = y_min.min(localization.y); - y_max = y_max.max(localization.y); - } - Some((x_min, x_max.max(x_min), y_min, y_max.max(y_min))) -} - -fn localization_time_bounds(results: &EveLocalizationResults) -> Option<(f64, f64)> { - if let Some(first) = results.localizations.first() { - let mut min_time = first.timestamp_us; - let mut max_time = first.timestamp_us; - for localization in &results.localizations { - min_time = min_time.min(localization.timestamp_us); - max_time = max_time.max(localization.timestamp_us); - } - return Some((min_time as f64, max_time.max(min_time) as f64)); - } - - if results.frame_window_end_us >= results.frame_window_start_us { - return Some(( - results.frame_window_start_us as f64, - results.frame_window_end_us as f64, - )); - } - - None -} - -pub fn localization_row_id(localization: &EveLocalization) -> u64 { - localization.cluster_id.rotate_left(3) - ^ localization.timestamp_us - ^ localization.x.to_bits().rotate_left(7) - ^ localization.y.to_bits().rotate_left(19) - ^ localization.sigma_x.to_bits().rotate_left(31) - ^ localization.sigma_y.to_bits().rotate_left(43) - ^ localization.fit_residual.to_bits().rotate_left(53) - ^ (localization.n_events as u64).rotate_left(11) - ^ (localization.fit_method.index() as u64).rotate_left(59) - ^ localization.span_start_us.rotate_left(17) - ^ localization.span_end_us.rotate_left(29) -} - pub fn rejected_fit_row_id(row: &RejectedFitRow) -> u64 { row.timestamp_us ^ row.cluster_id.rotate_left(7) @@ -2344,27 +1895,6 @@ fn estimate_timestamp_us(events: &[EveEvent], x: f64, y: f64, radius: f64) -> u6 } } -pub fn to_localization_results(results: &EveLocalizationResults) -> LocalizationResults { - LocalizationResults { - localizations: results - .localizations - .iter() - .map(|localization| Localization { - x: localization.x, - y: localization.y, - sigma_x: localization.sigma_x, - sigma_y: localization.sigma_y, - amplitude: 0.0, - background: 0.0, - timestamp_us: localization.timestamp_us, - fit_error: localization.fit_residual, - }) - .collect(), - frame_window_start_us: results.frame_window_start_us, - frame_window_end_us: results.frame_window_end_us, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/plugins/evesmlm-fitting/src/log_gaussian.rs b/plugins/evesmlm-fitting/src/log_gaussian.rs index ad59c90..241e052 100644 --- a/plugins/evesmlm-fitting/src/log_gaussian.rs +++ b/plugins/evesmlm-fitting/src/log_gaussian.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_candidates::EveCluster; +use evesmlm_types::EveCluster; use nalgebra::{DMatrix, DVector}; use crate::FitEstimate; diff --git a/plugins/evesmlm-fitting/src/mean_xy.rs b/plugins/evesmlm-fitting/src/mean_xy.rs index 95462b8..1c95852 100644 --- a/plugins/evesmlm-fitting/src/mean_xy.rs +++ b/plugins/evesmlm-fitting/src/mean_xy.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_candidates::EveCluster; +use evesmlm_types::EveCluster; use crate::FitEstimate; diff --git a/plugins/evesmlm-fitting/src/phasor.rs b/plugins/evesmlm-fitting/src/phasor.rs index dccdbb9..cba433b 100644 --- a/plugins/evesmlm-fitting/src/phasor.rs +++ b/plugins/evesmlm-fitting/src/phasor.rs @@ -1,6 +1,6 @@ use std::f64::consts::TAU; -use augur_plugin_evesmlm_candidates::EveCluster; +use evesmlm_types::EveCluster; use num_complex::Complex64; use crate::{mean_xy, FitEstimate}; diff --git a/plugins/evesmlm-fitting/src/radial_symmetry.rs b/plugins/evesmlm-fitting/src/radial_symmetry.rs index 6c45441..0acee29 100644 --- a/plugins/evesmlm-fitting/src/radial_symmetry.rs +++ b/plugins/evesmlm-fitting/src/radial_symmetry.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_candidates::EveCluster; +use evesmlm_types::EveCluster; use crate::FitEstimate; diff --git a/plugins/evesmlm-postproc/Cargo.toml b/plugins/evesmlm-postproc/Cargo.toml index ec547d4..b18013e 100644 --- a/plugins/evesmlm-postproc/Cargo.toml +++ b/plugins/evesmlm-postproc/Cargo.toml @@ -12,6 +12,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] augur-plugin-api.workspace = true augur-plugin-types.workspace = true -augur-plugin-evesmlm-fitting = { path = "../evesmlm-fitting" } +evesmlm-types.workspace = true nalgebra = "0.33" serde_json.workspace = true diff --git a/plugins/evesmlm-postproc/src/drift_correction.rs b/plugins/evesmlm-postproc/src/drift_correction.rs index 9f16e50..ce85aba 100644 --- a/plugins/evesmlm-postproc/src/drift_correction.rs +++ b/plugins/evesmlm-postproc/src/drift_correction.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_fitting::EveLocalizationResults; +use evesmlm_types::EveLocalizationResults; pub fn estimate_correction_shift( reference_points: &[(f64, f64)], diff --git a/plugins/evesmlm-postproc/src/evaluation.rs b/plugins/evesmlm-postproc/src/evaluation.rs index eac5e72..ee0bd25 100644 --- a/plugins/evesmlm-postproc/src/evaluation.rs +++ b/plugins/evesmlm-postproc/src/evaluation.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use augur_plugin_evesmlm_fitting::{EveLocalization, EveLocalizationResults}; +use evesmlm_types::{EveLocalization, EveLocalizationResults}; const DEFAULT_PSF_SIZE: usize = 9; const TRACK_LINK_RADIUS_PX: f64 = 1.5; diff --git a/plugins/evesmlm-postproc/src/filtering.rs b/plugins/evesmlm-postproc/src/filtering.rs index 752f125..a0b2de7 100644 --- a/plugins/evesmlm-postproc/src/filtering.rs +++ b/plugins/evesmlm-postproc/src/filtering.rs @@ -1,4 +1,4 @@ -use augur_plugin_evesmlm_fitting::{EveLocalization, EveLocalizationResults}; +use evesmlm_types::{EveLocalization, EveLocalizationResults}; pub fn filter_results( results: &EveLocalizationResults, diff --git a/plugins/evesmlm-postproc/src/lib.rs b/plugins/evesmlm-postproc/src/lib.rs index 4413f9d..44b0739 100644 --- a/plugins/evesmlm-postproc/src/lib.rs +++ b/plugins/evesmlm-postproc/src/lib.rs @@ -15,13 +15,13 @@ use augur_plugin_api::{ PluginInput, SettingItem, SettingKind, SettingsSchema, SettingsSection, StatusEntry, CTX_GLOBAL_SETTINGS, }; -pub use augur_plugin_evesmlm_fitting::{ +use augur_plugin_types::CTX_LOCALIZATION_RESULTS; +use evaluation::EvaluationState; +pub use evesmlm_types::{ current_localizations_dataset, current_localizations_registry_for_results, localization_row_id, to_localization_results, EveLocalization, EveLocalizationResults, FitMethod, CTX_EVE_LOCALIZATION_RESULTS, CURRENT_LOCALIZATIONS_DATASET_ID, CURRENT_LOCALIZATIONS_LAYER_ID, }; -use augur_plugin_types::CTX_LOCALIZATION_RESULTS; -use evaluation::EvaluationState; use serde_json::{json, Value}; const OVERLAY_COLOR: [u8; 4] = [90, 170, 255, 220]; @@ -690,7 +690,7 @@ mod tests { #[test] fn current_localizations_descriptor_matches_fitting() { - use augur_plugin_evesmlm_fitting::current_localizations_registry_for_results as fitting_registry; + use evesmlm_types::current_localizations_registry_for_results as fitting_registry; let results = EveLocalizationResults::default(); let fitting = fitting_registry(&results, None); let postproc = current_localizations_registry_for_results(&results, None); 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 193dbd9..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** @@ -150,11 +164,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,11 +178,16 @@ 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 the **file line number**, which is what your editor and spreadsheet both show. +Files saved by a spreadsheet load as-is: Windows line endings and the byte-order mark that Excel's +"CSV UTF-8" writes are both absorbed, so the first column is not silently reported missing. + Two things the row form gives you that blocks cannot without one block per value: **a different duration per row** (1 Hz needs 40 s of cycles, 200 Hz does not), and **a `role` column**, so a file can open with its own background floor and pilot and then record the points scored against them — @@ -181,9 +198,17 @@ 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" + [defaults] duration_s = 10 settle_s = 2.0 +diff_on = 12 +diff_off = -7 [[block]] name = "frequency-ladder" @@ -196,6 +221,32 @@ 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. 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. 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, 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 +31.25 kHz at 500 kSa/s. The lower of this measurement bound and the 2 kHz drive +bound applies. ### Either way @@ -208,6 +259,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..32cfd84 100644 --- a/plugins/stage-a-a1/plugin.toml +++ b/plugins/stage-a-a1/plugin.toml @@ -5,5 +5,10 @@ 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" -host_commands = ["start_recording", "stop_recording"] +min_augur_version = "2.0.2" +host_commands = [ + "start_recording", + "stop_recording", + "apply_camera_configuration", + "restore_camera_configuration", +] 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_fc_flux_discriminator_resume.csv b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator_resume.csv new file mode 100644 index 0000000..b0502e2 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator_resume.csv @@ -0,0 +1,299 @@ +# 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. +# +# +# FORTSETZUNG: erzeugt aus a1_fc_flux_discriminator.csv +# (sha256 336668dfb5520c0994f4cc9662da1a01f9967509dbcaaadae19d47ab26264953). +# Punkte 1-41 von 297 sind in Session A1-20260907-bb52 bereits aufgenommen und +# hier entfernt. Diese Datei beginnt bei Punkt 42 des Originals (grid_a, mean_u=0.15, +# 640 Hz, depth_a=1.30). Punktnummern in dieser Datei zaehlen wieder ab 1. +# Bias-v1, ROI, Probe, Optik, Kalibration und PD-Last muessen unveraendert bleiben. +# Umfang: 256 Recordings, ~145 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,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_fc_flux_discriminator_resume2.csv b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator_resume2.csv new file mode 100644 index 0000000..dd87393 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator_resume2.csv @@ -0,0 +1,287 @@ +# 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. +# +# +# FORTSETZUNG 2: erzeugt aus a1_fc_flux_discriminator.csv +# (sha256 336668dfb5520c0994f4cc9662da1a01f9967509dbcaaadae19d47ab26264953). +# Punkte 1-53 von 297 sind in Session A1-20260907-bb52 bereits aufgenommen: +# Punkte 1-41 aus a1_fc_flux_discriminator.csv (12:04-12:23 Uhr), +# Punkte 42-53 aus a1_fc_flux_discriminator_resume.csv Punkte 1-12 (13:52-13:57 Uhr). +# Diese Datei beginnt bei Punkt 54 des Originals (windows_a, mean_u=0.30, +# 350 Hz, depth_a=1.70). Punktnummern in dieser Datei zaehlen wieder ab 1. +# Bias-v1, ROI, Probe, Optik, Kalibration und PD-Last muessen unveraendert bleiben. +# Umfang: 244 Recordings, ~139 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.30 +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/lib.rs b/plugins/stage-a-a1/src/lib.rs index 14acc6a..7d5eceb 100644 --- a/plugins/stage-a-a1/src/lib.rs +++ b/plugins/stage-a-a1/src/lib.rs @@ -4,14 +4,20 @@ //! 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, the CSV splitter and the declarative +/// recording protocol. All three live in the contract crate: A4 gathers the +/// same host-written CSV, and the modulation and photodiode owners validate +/// the shipped protocols against their own limits. A plugin crate must never +/// depend on another plugin crate — every one of them exports +/// `augur_plugin_vtable`, and two of those in one binary do not link on +/// Windows or Linux (ADR 031). +pub use stage_a_plugin_contract::{csv, protocol, telemetry as sensor}; + pub use runtime::StageAA1Plugin; pub use types::{CameraEvent, Polarity}; diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs index 829abcf..187a017 100644 --- a/plugins/stage-a-a1/src/runtime.rs +++ b/plugins/stage-a-a1/src/runtime.rs @@ -36,6 +36,7 @@ //! `q_p(a, f)` fit is computed offline from the recordings; the live plot is a //! quicklook. +use stage_a_plugin_contract::{ModulationResponseV1, RequestOutcomeV1}; use std::cell::RefCell; use std::collections::BTreeMap; use std::collections::HashSet; @@ -43,25 +44,28 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use augur_plugin_api::{ - export_plugin, 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, + 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}; +use sha2::{Digest, Sha256}; use stage_a_plugin_contract::{ - ClientId, ConnectionStateV1, LeaseId, 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}; @@ -96,6 +100,15 @@ 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; +/// Wait this long before repeating a point whose recording failed. A bench +/// wobble — a refused camera start, a dropped trigger marker — costs seconds +/// instead of a measurement point. +const POINT_RETRY_DELAYS_MS: [u64; 3] = [1_000, 2_000, 4_000]; +/// End the run once this many points in a row are lost. Retrying forever turns +/// a broken bench into a full file of empty points; stopping at the first loss +/// throws away a survey that only stumbled. +const MAX_CONSECUTIVE_FAILED_POINTS: usize = 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 @@ -149,6 +162,28 @@ const FREQ_CONFIRM_BASE_MS: u64 = 20_000; /// of the old and the new drive. const FREQ_CONFIRM_CYCLES: f64 = 4.0; +/// Renew a held lease once less than this much of the owner's *granted* window +/// is left. +/// +/// Both owners cap the TTL they hand out — a client that dies must not hold the +/// drive indefinitely, so the cap is a dead-man switch and is right. What that +/// means here is that the whole-run TTL a leased run asks for is emphatically +/// not what it gets: ask for forty minutes, be granted a minute. Renewing once +/// per point was therefore only ever correct for points shorter than the cap. +/// A longer one (the shipped example protocol has a 40 s row, and every row +/// also pays the start/stop handshake) ran past the granted deadline mid +/// recording, and the owner did what an expired lease must do — STOP, output +/// off. The run then lost the drive, the phase-0 trigger and the photodiode's +/// optical summary at once, and reported three unrelated-looking failures. +/// +/// So the run renews against the deadline the owner actually advertises, not +/// against the one it asked for. +const LEASE_RENEW_MARGIN_MS: u64 = 20_000; +/// Shortest gap between two heartbeat renewals of the same lease. The owner's +/// snapshot lags a renewal by a tick or two, so without this the margin test +/// re-fires on every control tick until the new deadline comes back. +const LEASE_RENEW_MIN_INTERVAL_MS: u64 = 2_000; + /// Absolute/relative tolerance for "the measured `a` reached the sweep target". fn sweep_tolerance(target_a: f64) -> f64 { (target_a * 0.10).max(0.05) @@ -179,6 +214,29 @@ fn frequency_label(hz: f64) -> String { format!("{hz:.3} Hz") } +/// The detector placement in the words the photodiode plugin's own selector +/// uses, so a refusal names the setting the operator has to look at. +fn placement_label(placement: stage_a_plugin_contract::PhotodiodePlacementV1) -> &'static str { + match placement { + stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort => "rejected port", + stage_a_plugin_contract::PhotodiodePlacementV1::CameraPath => "camera path", + stage_a_plugin_contract::PhotodiodePlacementV1::EmissionPath => "emission path", + } +} + +/// 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 { @@ -357,6 +415,26 @@ 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, + /// Why the photodiode published no summary, latched on the same tick and + /// for the same reason as `optical`. + /// + /// The refusal is the whole report a lost point leaves behind, and read + /// after both finalizes it describes the bench *after* the recording: the + /// finalizes can restart the stream, and a just-restarted ring reports a + /// window 0.2 s long whatever the real gate was. + optical_blocker: Option, } /// Where the amplitude sweep is within its per-point cycle. @@ -783,6 +861,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 and confirming a complete configuration. + 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 @@ -792,6 +872,9 @@ enum ProtocolPhase { Settling, /// The recording coordinator owns this phase. Recording, + /// All recording work is complete; the host is restoring the pre-run + /// configuration. + RestoringCamera, } /// One protocol run: walk the parsed points, retargeting all three axes at @@ -804,11 +887,35 @@ enum ProtocolPhase { /// implicitly at whatever the operator last armed. struct ProtocolRun { plan: protocol::Protocol, + source_path: String, + source_sha256: String, + source_text: String, + reused: std::collections::BTreeSet, phase: ProtocolPhase, index: usize, lease_id: LeaseId, lease_granted: bool, lease_req: u64, + 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<(CameraBiasOffsetsV1, SensorBiasReadbackV1, f64)>, + restore_req: Option, + restore_attempts: u8, + point_retries: usize, + consecutive_failures: usize, + /// `PrepareA1` in flight. No point is sent until the controller confirms + /// A1 mode and running acquisition. + prepare_req: Option, + /// The controller has been put into A1 mode for this run. + firmware_prepared: bool, + 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 /// independently, and recording after two of them would file the run under @@ -816,6 +923,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 @@ -832,11 +940,36 @@ struct ProtocolRun { } impl ProtocolRun { + fn remaining_seconds(&self) -> f64 { + self.plan + .points + .iter() + .enumerate() + .filter(|(index, _)| *index >= self.index && !self.reused.contains(index)) + .map(|(_, point)| point.duration_s as f64 + point.settle_s) + .sum() + } + fn point(&self) -> Option<&protocol::ProtocolPoint> { self.plan.points.get(self.index) } } +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"); + } + if snapshot.digital_filter.erc_enabled != Some(false) { + return Some("ERC must be explicitly reported 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 { @@ -867,6 +1000,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. /// @@ -907,6 +1043,8 @@ pub struct StageAA1Plugin { /// aborted); the sweep uses this to decide between advancing and stopping. recording_completed_ok: bool, request_seq: u64, + modulation_requests: Vec<(PluginServiceRequest, u64)>, + modulation_poll_ms: u64, pd_revision_seq: u64, /// Role latched by the Start/Pilot/Background buttons, consumed next tick. pending_role: Option, @@ -975,6 +1113,15 @@ pub struct StageAA1Plugin { /// Output folder the lock table was last read for, so it is re-read only /// when the experiment folder changes. loaded_locks_folder: Option, + /// Whether the last finished recording produced no sensor readout, so the + /// panel can name the host switch that governs it. Observed rather than + /// asked: the host does not publish whether it is recording telemetry. + last_run_had_no_readout: bool, + // -- lease heartbeats (see LEASE_RENEW_MARGIN_MS) -- + /// When the modulation lease was last renewed by the heartbeat. + mod_renewed_ms: u64, + /// When the photodiode lease was last renewed by the heartbeat. + pd_renewed_ms: u64, // -- momentary-button press forwarding (see PressLatch) -- press_start: PressLatch, press_pilot: PressLatch, @@ -1009,6 +1156,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, @@ -1024,6 +1172,8 @@ impl Default for StageAA1Plugin { recording: Recording::idle(), recording_completed_ok: false, request_seq: 0, + modulation_requests: Vec::new(), + modulation_poll_ms: 0, pd_revision_seq: 0, pending_role: None, loaded_key: None, @@ -1055,6 +1205,9 @@ impl Default for StageAA1Plugin { protocol: None, a0_locks: Vec::new(), loaded_locks_folder: None, + last_run_had_no_readout: false, + mod_renewed_ms: 0, + pd_renewed_ms: 0, press_start: PressLatch::default(), press_pilot: PressLatch::default(), press_background: PressLatch::default(), @@ -1105,6 +1258,8 @@ impl Recording { pd_valid: false, pd_rejected: false, failure: None, + optical: None, + optical_blocker: None, } } @@ -1211,6 +1366,13 @@ impl StageAA1Plugin { self.message = "Frequency sweep stop requested".into(); } if let Some(protocol) = self.protocol.as_mut() { + if protocol.phase == ProtocolPhase::RestoringCamera + && protocol.restore_req.is_none() + && !protocol.restore_confirmed + { + protocol.restore_attempts = 0; + protocol.restore_error = None; + } protocol.stop_requested = true; self.message = "Protocol stop requested".into(); } @@ -1221,6 +1383,104 @@ impl StageAA1Plugin { self.protocol_pending = false; } + /// Whether anything the Record section started is still in flight. + /// + /// The same set [`Self::request_stop`] winds down. A discontinuity that + /// arrives between two points of a run is still *inside* that run, so it + /// must not be treated as an idle-time reset. + fn automation_active(&self) -> bool { + self.recording.is_active() + || self.sweep.is_some() + || self.a0_lock.is_some() + || self.freq_sweep.is_some() + || self.protocol.is_some() + } + + /// The modulation lease this run holds, and how much longer it still needs + /// it. Outermost runner first — a nested run inherits the enclosing lease + /// id, so the outermost one names the lease and owns the remaining time. + /// + /// `None` until the owner has granted it: renewing a lease that does not + /// exist yet is rejected, and the acquire is already in flight. + fn held_modulation_lease(&self) -> Option<(LeaseId, u64)> { + if let Some(run) = self.protocol.as_ref().filter(|run| run.lease_granted) { + return Some(( + run.lease_id.clone(), + Self::protocol_lease_ttl_ms(&run.plan, run.index), + )); + } + if let Some(sweep) = self.freq_sweep.as_ref().filter(|s| s.lease_granted) { + let remaining = sweep.points.len().saturating_sub(sweep.index); + return Some(( + sweep.lease_id.clone(), + self.freq_sweep_lease_ttl_ms(remaining, sweep.mode), + )); + } + if let Some(lock) = self.a0_lock.as_ref().filter(|l| l.lease_granted) { + return Some((lock.lease_id.clone(), self.a0_lock_lease_ttl_ms())); + } + if let Some(sweep) = self.sweep.as_ref().filter(|s| s.lease_granted) { + let remaining = sweep.total().saturating_sub(sweep.index); + return Some((sweep.lease_id.clone(), self.sweep_lease_ttl_ms(remaining))); + } + None + } + + /// Whether `lease` is the one A1 is holding right now, per the owner's own + /// snapshot. Renewing on our own bookkeeping alone would keep re-asking + /// after the owner had already dropped it. + fn owner_holds(lease: Option<&LeaseSnapshotV1>, held: &LeaseId) -> Option { + let lease = lease?; + (&lease.lease_id == held && lease.holder.as_str() == A1_PLUGIN_ID) + .then_some(lease.expires_at_unix_ms) + } + + /// Keep both leases alive against the deadline each owner advertises. + /// + /// Runs on every control tick, ahead of the runners: the owners cap the TTL + /// they grant well below the length of a survey (see + /// [`LEASE_RENEW_MARGIN_MS`]), so a run that renewed only when it moved to + /// its next point lost the drive in the middle of any point longer than the + /// cap. + fn drive_lease_heartbeat(&mut self, context: &mut impl RecordingControl) { + let now_ms = now_unix_ms(); + let due = |last_ms: u64, expires_at: u64| { + now_ms.saturating_sub(last_ms) >= LEASE_RENEW_MIN_INTERVAL_MS + && expires_at.saturating_sub(now_ms) <= LEASE_RENEW_MARGIN_MS + }; + + if let Some((lease_id, ttl_ms)) = self.held_modulation_lease() { + let expires_at = Self::owner_holds( + self.modulation.as_ref().and_then(|s| s.lease.as_ref()), + &lease_id, + ); + if expires_at.is_some_and(|at| due(self.mod_renewed_ms, at)) { + let request = + self.modulation_request(ModulationCommandV1::RenewLease { ttl_ms }, &lease_id); + context.request_service(&request); + self.mod_renewed_ms = now_ms; + } + } + + // The photodiode lease covers one recording, and A1 asks for its + // duration plus slack — which the owner caps just as hard, so any + // recording longer than the cap used to be finalized underneath itself + // as `LeaseExpired` and left no optical summary for the sidecar. + if self.recording.is_active() && self.recording.lease_granted { + let held = self.recording.lease_id.clone(); + let expires_at = Self::owner_holds( + self.photodiode.as_ref().and_then(|s| s.lease.as_ref()), + &held, + ); + if expires_at.is_some_and(|at| due(self.pd_renewed_ms, at)) { + let ttl_ms = self.photodiode_lease_ttl_ms(); + let request = self.photodiode_request(PhotodiodeCommandV1::RenewLease { ttl_ms }); + context.request_service(&request); + self.pd_renewed_ms = now_ms; + } + } + } + /// Sets the concise operator-facing recording result. fn note(&mut self, message: impl Into) { self.message = message.into(); @@ -1505,40 +1765,78 @@ 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 selected placement can never anchor a measured `a`, read from + /// the photodiode's published placement and dark provenance rather than + /// from a live window. + /// + /// The estimator's other gates need the drive to be running, so they cannot + /// be asked before a protocol has commanded its first point. This one can, + /// and it is fatal for the whole file rather than for one point: a direct + /// placement without a lamp-off dark reference fails every quantitative + /// sidecar the survey would write, whatever the drive does afterwards. + fn direct_dark_reference_blocker(&self) -> Option { + if self.depth_source != DepthSource::Photodiode { + return None; + } + let state = self.photodiode.as_ref()?; + if state.placement == stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort + || state.dark_reference.is_some() + { + return None; + } + Some(format!( + "the photodiode is sampling the {} and has no lamp-off dark reference — block the \ + light and press Capture lamp-off dark in the photodiode plugin, or enter a manual \ + value (or switch \"Depth a source\" to the commanded drive to work open loop)", + placement_label(state.placement) + )) + } + + /// 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 { @@ -1958,28 +2256,57 @@ 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}")); } - if let Some(config) = self - .modulation - .as_ref() - .and_then(|s| s.acknowledged.as_ref()) - .and_then(|t| t.a1_configuration.as_ref()) - { - meta.insert("center_dac".into(), config.center_dac.to_string()); - meta.insert("amplitude_dac".into(), config.amplitude_dac.to_string()); - } 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()); + 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( + "a1_protocol_source_file".into(), + file.to_string_lossy().into_owned(), + ); + } + 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 { + 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()); + } + } + } // 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. @@ -2061,6 +2388,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) { @@ -2078,6 +2435,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. @@ -2150,6 +2513,12 @@ impl StageAA1Plugin { RecRole::Normal | RecRole::EventCount => {} } + if let Err(error) = self.write_sidecar() { + self.note_failure(format!( + "Cannot save measurement metadata before recording: {error}" + )); + return; + } self.start_camera(context); } @@ -2190,6 +2559,7 @@ impl StageAA1Plugin { command: HostCommand::StartRecording { run_id: stem.clone(), base_path: format!("{subdir}/{stem}.raw"), + root_dir: Some(self.recording.folder.clone()), metadata, }, }); @@ -2211,12 +2581,18 @@ impl StageAA1Plugin { )); } - fn acquire_photodiode(&mut self, context: &mut impl RecordingControl) { - let ttl_ms = self - .recording + /// How much longer the photodiode is needed: the recording's own length + /// plus the start/stop handshake. The owner caps what it grants, so the + /// heartbeat re-asks — see [`LEASE_RENEW_MARGIN_MS`]. + fn photodiode_lease_ttl_ms(&self) -> u64 { + self.recording .duration_s .saturating_mul(1_000) - .saturating_add(60_000); + .saturating_add(60_000) + } + + fn acquire_photodiode(&mut self, context: &mut impl RecordingControl) { + let ttl_ms = self.photodiode_lease_ttl_ms(); let request = self.photodiode_request(PhotodiodeCommandV1::AcquireLease { ttl_ms }); self.recording.lease_req = request.request_id; context.request_service(&request); @@ -2323,6 +2699,33 @@ impl StageAA1Plugin { } } + /// Which artifacts never arrived, named one by one. + /// + /// "not every file was finalized" is the whole report an unattended run + /// leaves behind for the point it lost, and it names no subsystem to look + /// at. Say which side did not deliver. + fn unfinalized_artifacts(&self) -> String { + let mut missing = Vec::new(); + if !self.recording.cam_complete { + missing.push("the camera RAW was not finalized"); + } + if !self.recording.pd_finalized { + missing.push("the photodiode never finalized its PDQ"); + } else if !self.recording.pd_valid { + missing.push("the photodiode rejected its own recording"); + } + if self.recording.pd_pdq_path.is_none() { + missing.push("no PDQ path was reported"); + } + if self.recording.pd_sidecar_path.is_none() { + missing.push("no photodiode sidecar path was reported"); + } + if missing.is_empty() { + return "not every file was finalized".into(); + } + missing.join("; ") + } + fn finish_recording(&mut self, context: &mut impl RecordingControl) { let clean = self.recording.cam_complete && self.recording.pd_finalized @@ -2337,7 +2740,8 @@ impl StageAA1Plugin { .recording .failure .clone() - .unwrap_or_else(|| "not every file was finalized".into()); + .unwrap_or_else(|| self.unfinalized_artifacts()); + let saved = sidecar.is_ok(); let message = match (sidecar, clean) { (Ok(path), true) => format!("Saved recording {} → {path}", self.recording.id), (Ok(path), false) => format!( @@ -2349,19 +2753,15 @@ impl StageAA1Plugin { self.recording.id ), }; - self.recording_completed_ok = clean; + self.recording_completed_ok = clean && saved; self.release_and_idle(context, message); } /// Collects the finalized artifacts into `//`. /// - /// The camera RAW and the PDQ are written by two other owners against their - /// own roots — the host resolves plugin recording paths below *its* output - /// directory and rejects absolute ones, and the photodiode resolves PDQ - /// paths below *its* data directory. Left alone, one measurement scatters - /// across up to three unrelated folders. Both files are closed and hashed - /// by the time their receipts arrive, so moving them here is safe and makes - /// this plugin's output folder authoritative for the whole measurement. + /// Both owners receive the chosen recording root. Older hosts can ignore + /// that optional field and use their own directory, so retain the existing + /// collection step as a fallback after both files are closed and hashed. fn gather_into_measurement_folder(&mut self) { let dir = PathBuf::from(&self.recording.folder).join(&self.recording.id); if std::fs::create_dir_all(&dir).is_err() { @@ -2389,6 +2789,7 @@ impl StageAA1Plugin { // from the bench conditions it was taken under at the first move. // It is rewritten column-wise on the way in — see `sensor`. self.recording.sensor_readout_path = self.gather_sensor_readout(&dir, &raw); + self.last_run_had_no_readout = self.recording.sensor_readout_path.is_none(); } // PDQ receipts report the *label* A1 asked for, which is relative to the // photodiode's data directory — resolve it before touching the file, and @@ -2428,7 +2829,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; } @@ -2490,13 +2891,22 @@ impl StageAA1Plugin { .as_ref() .map(|state| state.owner_instance.clone()); envelope.issued_at_unix_ms = now_unix_ms(); - PluginServiceRequest { + envelope.requested_revision = Some(SemanticRevision( + self.modulation + .as_ref() + .and_then(|state| state.requested.as_ref()) + .map_or(1, |target| target.revision.0 + 1), + )); + let request = PluginServiceRequest { request_id, source_plugin_id: A1_PLUGIN_ID.into(), target_plugin_id: MODULATION_PLUGIN_ID.into(), service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), payload: serde_json::to_value(&envelope).unwrap_or(Value::Null), - } + }; + self.modulation_requests + .push((request.clone(), now_unix_ms())); + request } fn modulation_connected(&self) -> bool { @@ -3513,7 +3923,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 @@ -3532,8 +3943,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() ), ); @@ -3547,7 +3958,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, @@ -4241,12 +4653,14 @@ impl StageAA1Plugin { // ---- declarative protocol runs ------------------------------------- - /// Lease TTL covering the whole protocol, plus a minute of slack. + /// How much longer the whole protocol still needs the drive, plus a minute + /// of slack. /// - /// One lease for the whole file, like the frequency ladder: a TTL that - /// expired between points would hand the drive back to the operator's - /// armed settings mid-survey, and the remaining points would record - /// against them without saying so. + /// One lease for the whole file, like the frequency ladder: handing the + /// drive back to the operator's armed settings mid-survey would let the + /// remaining points record against them without saying so. This is what + /// A1 *asks* for, not what it gets — the owner caps the TTL it grants, and + /// [`Self::drive_lease_heartbeat`] is what actually keeps the lease alive. fn protocol_lease_ttl_ms(plan: &protocol::Protocol, from: usize) -> u64 { let remaining: f64 = plan.points[from.min(plan.points.len())..] .iter() @@ -4281,15 +4695,6 @@ impl StageAA1Plugin { self.message = "Choose a protocol file first".into(); return; } - if !self.modulation_connected() { - self.message = - "The modulation plugin is not connected — connect it to drive the protocol".into(); - return; - } - if let Some(blocker) = self.photodiode_blocker() { - self.message = blocker; - return; - } let text = match std::fs::read_to_string(&path) { Ok(text) => text, Err(error) => { @@ -4304,62 +4709,255 @@ impl StageAA1Plugin { return; } }; - // 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. - let lowest = plan + let source_sha256 = format!("{:x}", Sha256::digest(text.as_bytes())); + let id = self.ensure_measurement_id(); + let folder = Path::new(self.output_folder.trim()).join(&id); + let reused = match completed_protocol_rows(&folder, &id, &source_sha256, plan.points.len()) + { + Ok(rows) => rows, + Err(error) => { + self.message = format!("Cannot inspect measurement folder: {error}"); + return; + } + }; + let remaining = plan.points.len() - reused.len(); + if remaining == 0 { + self.message = format!( + "Protocol '{}': all {} points already complete; {} reused, 0 new, 0 missing", + plan.name, + plan.points.len(), + reused.len() + ); + return; + } + self.loaded_key = None; + self.scan_measurement_folder(); + if !self.modulation_connected() { + self.message = + "The modulation plugin is not connected — connect it to drive the protocol".into(); + return; + } + if let Some(blocker) = self.photodiode_blocker() { + self.message = blocker; + return; + } + if let Some(blocker) = self.direct_dark_reference_blocker() { + self.message = format!("Protocol refused: {blocker}"); + return; + } + // A fixed protocol records commanded points for offline analysis. A + // live window from the previous frequency cannot qualify the next one. + let highest = plan .points .iter() .map(|point| point.frequency_hz) - .fold(f64::INFINITY, f64::min); - if lowest.is_finite() { - if let Err(reason) = self.optical_window_covers_a_cycle(lowest) { + .fold(0.0_f64, f64::max); + let acquisition_running = self.modulation.as_ref().is_some_and(|state| { + state.controller_mode.as_deref() == Some("A1") + && state.controller_state == stage_a_plugin_contract::ControllerStateV1::Running + }); + if acquisition_running { + if let Some(blocker) = self.photodiode_measurement_blocker(highest) { self.message = format!( - "Protocol refused at its lowest frequency ({}): {reason}", - frequency_label(lowest) + "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()); + 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 controls_camera { + ProtocolPhase::ApplyingCamera + } else { + ProtocolPhase::AcquiringLease + }; 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.points + .iter() + .enumerate() + .filter(|(index, _)| !reused.contains(index)) + .map(|(_, point)| point.duration_s as f64 + point.settle_s) + .sum(), + ); + let first_missing = (0..total) + .find(|index| !reused.contains(index)) + .unwrap_or(total); + let reused_count = reused.len(); self.message = format!( - "Protocol '{}': {total} recordings ({means} × ū, {frequencies} × f, {depths} × a), \ - about {minutes:.0} min of bench time — acquiring the modulation lease…", + "Protocol '{}': {total} points, {reused_count} reused, 0 new, {remaining} missing ({means} × ū, {frequencies} × f, {depths} × a), \ + about {bench_time} of bench time — preparing the camera and modulation lease…", plan.name ); self.protocol = Some(ProtocolRun { plan, - phase: ProtocolPhase::AcquiringLease, - index: 0, + source_path: path, + source_sha256, + source_text: text, + reused, + phase, + index: first_missing, lease_id, lease_granted: false, - lease_req, + lease_req: 0, + 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, + restore_req: None, + restore_attempts: 0, + point_retries: 0, + consecutive_failures: 0, + prepare_req: None, + firmware_prepared: false, + restore_confirmed: false, + restore_error: 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 controls_camera { + let request_id = self.next_request_id(); + let configuration = match camera_selection { + Some(protocol::CameraSelection::NamedProfile(name)) => { + CameraConfigurationSourceV1::NamedProfile { name } + } + Some(protocol::CameraSelection::Snapshot(snapshot)) => { + CameraConfigurationSourceV1::Snapshot { snapshot } + } + None => CameraConfigurationSourceV1::Current, + }; + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyCameraConfiguration { configuration }, + }); + 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(); + } + } + + fn append_protocol_event(&self, event: &str, detail: &str) -> Result<(), String> { + use std::io::Write; + let Some(run) = self.protocol.as_ref() else { + return Ok(()); + }; + let folder = Path::new(self.output_folder.trim()); + std::fs::create_dir_all(folder).map_err(|error| error.to_string())?; + let path = folder.join(format!( + "{}_progress.jsonl", + sanitize_stem(run.lease_id.as_str()) + )); + let point = run.point(); + let entry = json!({ + "schema": "stage-a.a1.progress.v1", "event": event, "detail": detail, + "at_unix_ms": now_unix_ms(), "protocol_sha256": run.source_sha256, + "protocol_path": run.source_path, "point_index": run.index + 1, + "point_total": run.plan.points.len(), "recorded": run.recorded, + "failed": run.failed, "point_label": point.map(|p| &p.block), + "frequency_hz": point.map(|p| p.frequency_hz), "depth_a": point.map(|p| p.depth_a), + "mean_u": point.map(|p| p.mean_u), "duration_s": point.map(|p| p.duration_s), + }); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| error.to_string())?; + writeln!(file, "{entry}").map_err(|error| error.to_string())?; + file.sync_data().map_err(|error| error.to_string()) } - /// 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; + } + + if run.camera_session_active { + if let Some(run) = self.protocol.as_mut() { + run.phase = ProtocolPhase::RestoringCamera; + run.finish_message = Some(message); + } + self.request_protocol_camera_restore(context); + self.message = + "Protocol stopped recording; restoring the pre-run camera settings…".into(); + return; + } + + 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) { + let message = match self.append_protocol_event("finished", &message) { + Ok(()) => message, + Err(error) => format!("{message}; cannot save protocol progress: {error}"), + }; if let Some(run) = self.protocol.take() { if run.lease_granted { let request = self.modulation_request( @@ -4377,6 +4975,13 @@ impl StageAA1Plugin { /// Renew the lease and retarget all three axes at the current point. fn send_protocol_point(&mut self, context: &mut impl RecordingControl) { + if let Err(error) = self.append_protocol_event("point_requested", "") { + self.finish_protocol( + context, + format!("Protocol stopped: cannot save point progress: {error}"), + ); + return; + } let Some(run) = self.protocol.as_ref() else { return; }; @@ -4384,8 +4989,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); @@ -4410,6 +5017,33 @@ impl StageAA1Plugin { context.request_service(&request); } + 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::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + }, + }); + 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 @@ -4423,7 +5057,10 @@ impl StageAA1Plugin { if let Some(run) = self.protocol.as_mut() { run.phase = ProtocolPhase::Retargeting; run.pending_reqs = pending; - run.skip_reason = None; + run.bias_req = bias_request; + run.bias_confirmation = 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!( @@ -4443,23 +5080,53 @@ impl StageAA1Plugin { }; let index = run.index; run.failed.push((index, reason.clone())); + run.consecutive_failures += 1; + let consecutive = run.consecutive_failures; let point = run.plan.points[index].clone(); self.message = format!( - "Protocol point {} (ū={:.2}, f={}, a={:.2}) skipped: {reason}", + "Protocol point {} (ū={:.2}, f={:.3} Hz, a={:.2}) skipped: {reason}", index + 1, point.mean_u, - frequency_label(point.frequency_hz), + point.frequency_hz, point.depth_a, ); + if let Err(error) = self.append_protocol_event("point_failed", &reason) { + self.finish_protocol( + context, + format!("Protocol stopped: {reason}; cannot save failure record: {error}"), + ); + return; + } + if consecutive >= MAX_CONSECUTIVE_FAILED_POINTS { + // The bench, not the point, is broken: the same failure is about to + // consume the rest of the file. + let message = format!( + "Protocol aborted at point {}: {consecutive} points in a row failed — last: {reason}", + index + 1 + ); + self.finish_protocol(context, message); + return; + } self.advance_protocol(context); } /// Step to the next point, or finish. fn advance_protocol(&mut self, context: &mut impl RecordingControl) { + if let Err(error) = self.append_protocol_event("point_finished", &self.message) { + self.finish_protocol( + context, + format!("Protocol stopped: cannot save point result: {error}"), + ); + return; + } let Some(run) = self.protocol.as_mut() else { return; }; run.index += 1; + while run.reused.contains(&run.index) { + run.index += 1; + } + run.point_retries = 0; run.last_activity_ms = now_unix_ms(); if run.index < run.plan.points.len() && !run.stop_requested { self.send_protocol_point(context); @@ -4471,9 +5138,11 @@ impl StageAA1Plugin { run.failed.clone(), run.plan.points.len(), ); + let reused = run.reused.len(); + let missing = total.saturating_sub(reused + recorded); let stopped = run.stop_requested; let mut message = format!( - "Protocol '{name}' {}: {recorded}/{total} recorded", + "Protocol '{name}' {}: {recorded}/{total} recorded, {reused} reused, {recorded} new, {missing} missing", if stopped { "stopped" } else { "finished" } ); if !failed.is_empty() { @@ -4512,13 +5181,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, ) @@ -4527,17 +5209,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( @@ -4548,6 +5251,72 @@ impl StageAA1Plugin { } return; } + // The controller stamps the phase-0 markers the photodiode + // measures `a` from only while it is in A1 mode. An A2 + // preparation leaves the comparator driving the camera trigger + // instead, and nothing puts the mode back — so a survey that + // followed A2 work measured nothing until the controller was + // power-cycled. State the mode rather than inherit it. + // Only when the acquisition is not already an A1 one: a + // mode change stops and restarts the photodiode stream, and a + // run that needs no change must not pay for that. The mode + // string alone does not prove it: A2's drive-synchronized + // capture configures `A1` too, at A2's own sample rate, so + // the A2 target it leaves behind counts as the wrong mode. + let needs_mode = self + .modulation + .as_ref() + .map(|state| { + state.controller_mode.as_deref() != Some("A1") + || state.controller_state + != stage_a_plugin_contract::ControllerStateV1::Running + || state + .requested + .as_ref() + .is_some_and(|target| target.a2_configuration.is_some()) + || state + .acknowledged + .as_ref() + .is_some_and(|target| target.a2_configuration.is_some()) + }) + .unwrap_or(false); + let prepare_req = if needs_mode + && self + .protocol + .as_ref() + .is_some_and(|run| !run.firmware_prepared) + { + let Some(lease_id) = self.protocol.as_ref().map(|run| run.lease_id.clone()) + else { + return; + }; + let request = + self.modulation_request(ModulationCommandV1::PrepareA1, &lease_id); + context.request_service(&request); + Some(request.request_id) + } else { + None + }; + if let (Some(request_id), Some(run)) = (prepare_req, self.protocol.as_mut()) { + run.prepare_req = Some(request_id); + run.firmware_prepared = true; + run.last_activity_ms = now_ms; + } + if self + .protocol + .as_ref() + .is_some_and(|run| run.prepare_req.is_some()) + { + if now_ms.saturating_sub(self.protocol.as_ref().unwrap().last_activity_ms) + > REPLY_TIMEOUT_MS + { + self.finish_protocol( + context, + "Protocol aborted: controller did not confirm A1 mode".into(), + ); + } + return; + } self.send_protocol_point(context); } ProtocolPhase::Retargeting => { @@ -4559,11 +5328,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; @@ -4577,6 +5346,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; } } @@ -4584,6 +5354,21 @@ impl StageAA1Plugin { if now_ms < settle_until_ms { return; } + if let Some(frequency) = self + .protocol + .as_ref() + .and_then(|run| run.point()) + .map(|point| point.frequency_hz) + { + if let Some(reason) = self.photodiode_measurement_blocker(frequency) { + if now_ms.saturating_sub(settle_until_ms) > REPLY_TIMEOUT_MS { + self.fail_protocol_point(context, reason); + } else { + self.message = format!("Waiting for photodiode readback: {reason}"); + } + return; + } + } let duration_s = self .protocol .as_ref() @@ -4629,16 +5414,102 @@ impl StageAA1Plugin { if self.recording_completed_ok { if let Some(run) = self.protocol.as_mut() { run.recorded += 1; + run.consecutive_failures = 0; } self.advance_protocol(context); } else { - let reason = self.message.clone(); - self.fail_protocol_point(context, reason); + self.recover_protocol_recording(context, now_ms); + } + } + ProtocolPhase::RestoringCamera => { + if restore_pending { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + 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 (restore_confirmed, restore_attempts, restore_error) = self + .protocol + .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 run = self.protocol.as_mut().expect("active protocol"); + let message = run.finish_message.as_deref().unwrap_or("Protocol ended"); + self.message = format!( + "{message} — ERROR: pre-run camera settings were not confirmed restored after {restore_attempts} attempts ({}). Press Stop to retry restoration.", + restore_error.unwrap_or_else(|| "unknown restore failure".into()) + ); + // Keep the camera recovery state, but stop holding the drive + // lease indefinitely while waiting for operator recovery. + if run.lease_granted { + run.lease_granted = false; + let lease_id = run.lease_id.clone(); + let request = self.modulation_request( + ModulationCommandV1::ReleaseLease { + safe_off: true, + reason: "a1 camera restoration failed".into(), + }, + &lease_id, + ); + context.request_service(&request); + } } } } } + /// Repeat a point whose recording failed, then hand it to the skip path. + /// + /// The recording coordinator is idle by the time this runs, so a repeat + /// cannot collide with a recording the host still holds. Each attempt + /// writes its own timestamped files and its own sidecar, so a partial + /// attempt stays in the measurement folder next to the one that worked + /// instead of being overwritten by it. + fn recover_protocol_recording(&mut self, context: &mut impl RecordingControl, now_ms: u64) { + let reason = self.message.clone(); + let Some(run) = self.protocol.as_mut() else { + return; + }; + if let Some(delay) = POINT_RETRY_DELAYS_MS.get(run.point_retries) { + run.point_retries += 1; + run.phase = ProtocolPhase::Settling; + run.settle_until_ms = now_ms.saturating_add(*delay); + self.message = format!( + "Protocol point {}: retry {}/{} in {} s: {reason}", + run.index + 1, + run.point_retries, + POINT_RETRY_DELAYS_MS.len(), + delay / 1_000 + ); + return; + } + run.point_retries = 0; + self.fail_protocol_point(context, reason); + } + /// Routes modulation-service replies belonging to the protocol run. fn on_protocol_reply(&mut self, reply: &PluginServiceReply) -> bool { let Some(run) = self.protocol.as_ref() else { @@ -4646,6 +5517,22 @@ impl StageAA1Plugin { }; let lease_req = run.lease_req; let is_retarget = run.pending_reqs.contains(&reply.request_id); + if run.prepare_req == Some(reply.request_id) { + if let PluginServiceOutcome::Rejected { message, .. } = &reply.outcome { + // Through `finish_message`, or the stop path's own closing line + // would overwrite the reason in the same tick. + let reason = format!("Protocol aborted: the controller refused A1 mode: {message}"); + self.message = reason.clone(); + if let Some(run) = self.protocol.as_mut() { + run.finish_message = Some(reason); + run.stop_requested = true; + } + } + if let Some(run) = self.protocol.as_mut() { + run.prepare_req = None; + } + return true; + } if reply.request_id == lease_req { match &reply.outcome { PluginServiceOutcome::Accepted { .. } => { @@ -4989,6 +5876,140 @@ 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_profile_provenance = provenance + .profile_name + .is_some() + .then(|| provenance.clone()); + run.camera_provenance = Some(provenance.clone()); + run.camera_confirmation = Some((*readback, *readback_age_s)); + 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}" + )); + 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::CameraConfigurationApplied { + snapshot, + provenance, + readback, + readback_age_s, + } => { + if let Some(run) = self.protocol.as_mut() { + run.bias_req = None; + 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; + } + } + 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 { .. } + ); + if let Some(run) = self.protocol.as_mut() { + run.restore_req = None; + run.last_activity_ms = now_ms; + 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(), + }; + run.restore_error = Some(detail); + } + } + return; + } + } + if reply.request_id == self.recording.cam_start_req { match &reply.outcome { HostCommandOutcome::RecordingStarted { @@ -5031,7 +6052,48 @@ impl StageAA1Plugin { } } + fn poll_modulation_requests(&mut self, context: &mut impl RecordingControl) { + let now = now_unix_ms(); + if now.saturating_sub(self.modulation_poll_ms) < 200 { + return; + } + self.modulation_poll_ms = now; + self.modulation_requests + .retain(|(_, sent)| now.saturating_sub(*sent) <= REPLY_TIMEOUT_MS); + for (request, _) in &self.modulation_requests { + context.request_service(request); + } + } + fn on_service_reply(&mut self, reply: &PluginServiceReply) { + let mut terminal = reply.clone(); + if self + .modulation_requests + .iter() + .any(|(request, _)| request.request_id == reply.request_id) + { + if let PluginServiceOutcome::Accepted { payload } = &reply.outcome { + if let Ok(response) = + serde_json::from_value::(payload.clone()) + { + if response.common.outcome == RequestOutcomeV1::InProgress { + return; + } + if response.common.outcome == RequestOutcomeV1::Rejected { + terminal.outcome = PluginServiceOutcome::Rejected { + code: "device_rejected".into(), + message: response.common.error.map_or_else( + || "controller rejected the command".into(), + |error| error.message, + ), + }; + } + } + } + self.modulation_requests + .retain(|(request, _)| request.request_id != reply.request_id); + } + let reply = &terminal; if self.on_sweep_reply(reply) || self.on_a0_lock_reply(reply) || self.on_freq_sweep_reply(reply) @@ -5092,6 +6154,20 @@ 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.phase == RecPhase::Running { + match self.fresh_optical_summary() { + Some(optical) => { + let optical = optical.clone(); + self.recording.optical = Some(optical); + self.recording.optical_blocker = None; + } + None => self.recording.optical_blocker = self.optical_summary_blocker(), + } + } match self.recording.phase { RecPhase::Idle => { if let Some(role) = self.pending_role.take() { @@ -5207,25 +6283,30 @@ 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() .and_then(|s| s.acknowledged.as_ref()); - let a1_config = modulation.and_then(|t| t.a1_configuration.as_ref()); let mod_optical = self .modulation .as_ref() .and_then(|state| state.optical_drive.as_ref()); - let optical = self.fresh_optical_summary(); - if optical.is_none() { - return Err( - "cannot write a quantitative A1 sidecar without a fresh photodiode optical \ - summary from a confirmed I_tot anchor" - .into(), - ); - } - let roi = self.host_roi.unwrap_or_default(); - + // 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.recording.start_unix_ms == 0) + .then(|| self.fresh_optical_summary()) + .flatten() + }); + let optical_unavailable = optical.is_none().then(|| { + self.recording + .optical_blocker + .clone() + .or_else(|| self.optical_summary_blocker()) + .unwrap_or_else(|| "no optical summary was available during recording".into()) + }); let raw_path = self .recording .cam_finalized_path @@ -5233,7 +6314,68 @@ 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(), + acquisition_complete: self.recording.cam_complete + && self.recording.pd_finalized + && self.recording.pd_valid + && self.recording.pd_pdq_path.is_some() + && self.recording.pd_sidecar_path.is_some(), + acquisition_failure: self.recording.failure.clone(), + scientific_status: "requires_offline_review".into(), + optical_unavailable, measurement_id: self.recording.id.clone(), file_stem: self.recording.stem.clone(), role: self.recording.role.label().into(), @@ -5246,8 +6388,16 @@ 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(), + 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 .sweep @@ -5318,52 +6468,47 @@ 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), - amplitude_dac: a1_config.map(|c| c.amplitude_dac), waveform: modulation .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), - }, - 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(), + 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), }, - sensor: self.recorded_sensor().map(|sensor| { - let codes = sensor.bias_codes.map(|readback| readback.current); - 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), - } + 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(), @@ -5380,8 +6525,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()) @@ -5392,23 +6535,22 @@ impl StageAA1Plugin { #[derive(Serialize)] struct SidecarDoc { + schema: String, + acquisition_complete: bool, + #[serde(skip_serializing_if = "Option::is_none")] + acquisition_failure: Option, + scientific_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + optical_unavailable: Option, 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")] @@ -5421,8 +6563,7 @@ struct SidecarDoc { #[serde(skip_serializing_if = "Option::is_none")] background: Option, modulation: ModulationSidecar, - optical: OpticalSidecar, - camera: CameraSidecar, + 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")] @@ -5431,6 +6572,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, @@ -5532,31 +6713,29 @@ 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, #[serde(skip_serializing_if = "Option::is_none")] - center_dac: Option, - #[serde(skip_serializing_if = "Option::is_none")] - amplitude_dac: Option, - #[serde(skip_serializing_if = "Option::is_none")] waveform: Option, } #[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")] @@ -5568,22 +6747,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, + dark_age_s: Option, } /// Bench conditions the sensor measured for itself at the start of the run. @@ -5608,18 +6778,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, } #[derive(Serialize)] @@ -5642,7 +6800,13 @@ struct FilesSidecar { photodiode_sidecar: Option, /// Compacted per-channel sensor readout for this run — the die /// temperature, pixel dead time and illumination the host polled while it - /// was recording. Absent when the source had no monitoring block. + /// was recording. + /// + /// Absent whenever the host wrote no telemetry companion. Usually that is + /// 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, } @@ -5728,6 +6892,99 @@ fn sibling_toml(raw_path: &str) -> Option { Some(parent.join(format!("{stem}.toml")).display().to_string()) } +/// Find explicitly completed original rows with their artifacts in the selected folder. +fn completed_protocol_rows( + folder: &Path, + id: &str, + sha256: &str, + total: usize, +) -> Result, String> { + let mut rows = std::collections::BTreeSet::new(); + if std::fs::symlink_metadata(folder).is_ok_and(|m| !m.is_dir()) { + return Err("measurement folder must be a real directory".into()); + } + let entries = match std::fs::read_dir(folder) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(rows), + Err(error) => return Err(error.to_string()), + }; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.ends_with("_config.toml") + || !entry.file_type().map_err(|e| e.to_string())?.is_file() + { + continue; + } + let Some(doc) = std::fs::read_to_string(entry.path()) + .ok() + .and_then(|text| toml::from_str::(&text).ok()) + else { + continue; + }; + if doc + .get("acquisition_complete") + .and_then(toml::Value::as_bool) + != Some(true) + || doc.get("acquisition_failure").is_some() + || doc.get("schema").and_then(toml::Value::as_str) != Some("stage-a.a1.sidecar.v2") + || doc.get("measurement_id").and_then(toml::Value::as_str) != Some(id) + || doc.get("file_stem").and_then(toml::Value::as_str) + != name.strip_suffix("_config.toml") + { + continue; + } + let Some(protocol) = doc.get("protocol") else { + continue; + }; + if protocol.get("source_sha256").and_then(toml::Value::as_str) != Some(sha256) + || protocol + .get("point_total") + .and_then(toml::Value::as_integer) + != Some(total as i64) + { + continue; + } + let Some(index) = protocol + .get("point_index") + .and_then(toml::Value::as_integer) + .filter(|index| *index > 0 && *index <= total as i64) + else { + continue; + }; + let Some(files) = doc.get("files") else { + continue; + }; + // Copied Windows datasets retain their original absolute paths. Only + // evidence inside the selected folder counts, never the old location. + let complete = [ + "camera_raw", + "camera_config_sidecar", + "photodiode_pdq", + "photodiode_sidecar", + ] + .iter() + .all(|key| { + let Some(path) = files.get(*key).and_then(toml::Value::as_str) else { + return false; + }; + let Some(name) = path + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty() && *name != "." && *name != "..") + else { + return false; + }; + std::fs::symlink_metadata(folder.join(name)) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) + }); + if complete { + rows.insert(index as usize - 1); + } + } + Ok(rows) +} + /// Parses the newest config sidecar in `folder` for measurement `id` whose stem /// carries `role_tag` (e.g. `_pilot`). Filenames embed a sortable timestamp, so /// the lexicographically largest matching name is the most recent. @@ -5887,7 +7144,14 @@ impl Plugin for StageAA1Plugin { // must not wipe the row's pilot windows, background floor, or // the response points collected across a sweep. The event fold // still resets: that timeline really did restart. - if self.recording.is_active() || self.sweep.is_some() { + // + // Every runner counts, not just a recording in flight: a + // protocol or ladder spends the gap between two points + // retargeting the drive, and the stop boundary of the point + // just finished lands squarely in it. Asking only about the + // recording wiped the survey's own pilot windows and + // background floor between every pair of points. + if self.automation_active() { self.camera_events.clear(); self.event_scratch.clear(); self.camera_markers_us.clear(); @@ -5929,6 +7193,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 @@ -6029,6 +7294,10 @@ impl Plugin for StageAA1Plugin { self.scan_measurement_folder(); self.load_a0_locks(); } + // Before the runners: a lease that lapses is not the runners' problem + // to notice, and the owner safe-offs the drive the moment it does. + self.poll_modulation_requests(context); + self.drive_lease_heartbeat(context); // Outermost first: the protocol and the frequency sweep each start the // stage below them, and each of those starts its own next stage, so one // tick carries a hand-off all the way down. They are mutually exclusive @@ -6333,7 +7602,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, }, @@ -6348,7 +7618,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, }, @@ -6897,13 +8168,20 @@ 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 {}/{} — {} reused, {} new, {} missing, {} skipped, about {} of bench time left", run.plan.name, (run.index + 1).min(run.plan.points.len()), run.plan.points.len(), + run.reused.len(), run.recorded, + run.plan.points.len().saturating_sub(run.reused.len() + run.recorded), run.failed.len(), + format_bench_time(run.remaining_seconds()), ))); // The per-point message is overwritten within the tick that skips a // point, so the most recent reason lives here instead of scrolling @@ -7051,6 +8329,20 @@ impl Plugin for StageAA1Plugin { ) ), })); + // The mode the phase-0 markers depend on, stated only when it is the + // wrong one. The photodiode's refusal tells the operator to check it + // and nothing in either panel rendered it, so the check had no answer. + if let Some(mode) = self + .modulation + .as_ref() + .and_then(|state| state.controller_mode.as_deref()) + .filter(|mode| *mode != "A1") + { + entries.push(StatusEntry::Text(format!( + "Controller: mode={mode} — the phase-0 markers `a` is measured from are stamped \ + only in A1. A protocol run asks for A1 itself; manual work here does not" + ))); + } // Silent when the host reports nothing (replay, or a camera without a // monitoring block) rather than printing three dashes. if let Some(sensor) = self.sensor { @@ -7071,6 +8363,21 @@ impl Plugin for StageAA1Plugin { sensor.age_s ))); } + // The point values above ride the context bus and are always + // there. The per-run *time series* is a separate host feature the + // operator switches on, and it is off by default — so a survey + // could record forty runs, keep the bench conditions of none of + // them, and say nothing until the analysis. A1 cannot ask the host + // whether it is on, but it can report that the last run produced + // no readout, which is the same fact one recording later. + if self.last_run_had_no_readout { + entries.push(StatusEntry::Text( + "Sensor readout: the last run wrote none — tick \"Record sensor monitoring\" \ + in the recording panel, or runs keep only the single reading above and not \ + the series" + .into(), + )); + } } if let Some((on, off)) = self.latest_rolling() { entries.push(StatusEntry::Text(format!( @@ -7299,6 +8606,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, @@ -7313,6 +8631,7 @@ mod tests { } // Same order as `process_control`: outermost supervisor first, so one // tick can carry a hand-off from the ladder down into a recording. + plugin.drive_lease_heartbeat(sink); plugin.drive_protocol(sink); plugin.drive_freq_sweep(sink); plugin.drive_a0_lock(sink); @@ -7373,7 +8692,8 @@ mod tests { }, capabilities: Vec::new(), lease: None, - controller_state: stage_a_plugin_contract::ControllerStateV1::Configured, + controller_state: stage_a_plugin_contract::ControllerStateV1::Running, + controller_mode: Some("A1".into()), active_run_id: None, requested: None, acknowledged: None, @@ -7387,6 +8707,7 @@ mod tests { valid_for_ms: 5_000, }, calibration_id: Some("pockels-test".into()), + optical_lobe: None, optical_drive: None, } } @@ -7439,10 +8760,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, @@ -7459,6 +8783,11 @@ mod tests { covered_cycles: Some(8.0), }), optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + reference_set_id: None, + load_ohms: None, + dark_reference: None, synchronization: stage_a_plugin_contract::SynchronizationV1::Unsynced { reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, detail: None, @@ -7562,9 +8891,10 @@ mod tests { PhotodiodeSummaryV1 { optical_summary: None, optical_unavailable: Some( - "no stretch of samples covers two whole modulation cycles between triggers \ - (0 trigger(s) in the last 3446784 samples) — lower the frequency, or raise the \ - photodiode cache length" + "no phase-0 trigger has arrived in the last 17.8 s — the controller stamps one \ + per modulation cycle only in mode=A1, and after A2 work the comparator drives \ + the trigger instead: check the modulation plugin's mode, that the drive is \ + armed and running, and the phase-0 cable" .into(), ), ..photodiode_measuring(1, 0.5) @@ -7582,7 +8912,7 @@ mod tests { let blocker = plugin.depth_a_blocker().expect("a withheld a has a reason"); assert!( - blocker.contains("two whole modulation cycles"), + blocker.contains("mode=A1"), "the owner's own words must survive: {blocker}" ); assert!( @@ -7633,7 +8963,7 @@ mod tests { max_dac: 900, 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, @@ -8085,7 +9415,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); } @@ -8124,23 +9457,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 { @@ -8197,6 +9540,11 @@ mod tests { last_finalized_recording: None, optical_summary: None, optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + reference_set_id: None, + load_ohms: None, + dark_reference: None, synchronization: SynchronizationV1::Unsynced { reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, detail: None, @@ -8247,10 +9595,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, @@ -8265,6 +9616,11 @@ mod tests { covered_cycles: Some(8.0), }), optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + reference_set_id: None, + load_ohms: None, + dark_reference: None, synchronization: SynchronizationV1::Unsynced { reason: UnsyncedReasonV1::NoLease, detail: None, @@ -8649,6 +10005,7 @@ mod tests { sha256: Sha256V1::parse("ab".repeat(32)).expect("sha"), frames_written: 1, sample_frames_written: 1, + marker_counts: None, sample_range: None, sample_rate_hz: Some(20_000), segment_count: 1, @@ -9326,6 +10683,136 @@ 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!("analysis_a = {latched}")), + "the recorded depth must come from the same window: {written}" + ); + let _ = std::fs::remove_dir_all(&folder); + } + + /// The reason has to be the one that held during the recording. Read after + /// both finalizes it describes the bench afterwards — and a finalize can + /// restart the stream, which reports a 0.2 s window whatever the gate was. + #[test] + fn a_refused_sidecar_quotes_the_reason_the_recording_ran_into() { + let folder = temp_folder("sidecar-latched-blocker"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.id = "metadata-test".into(); + if let Some(summary) = plugin.photodiode.as_mut() { + summary.optical_summary = None; + summary.optical_unavailable = Some( + "the photodiode stream restarted 0.2 s ago, which cleared the retained samples" + .into(), + ); + } + plugin.recording.optical_blocker = Some( + "no phase-0 trigger has arrived in the last 60.0 s — the controller stamps one per \ + modulation cycle only in mode=A1" + .into(), + ); + + let path = plugin + .write_sidecar() + .expect("metadata must survive a missing estimate"); + let error = std::fs::read_to_string(path).unwrap(); + assert!( + error.contains("mode=A1"), + "the recording's own reason must survive the finalizes: {error}" + ); + assert!( + !error.contains("restarted"), + "the post-finalize state is not the reason: {error}" + ); + 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); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.id = "metadata-test".into(); + 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 20.0 s)" + .into(), + ); + } + + let path = plugin + .write_sidecar() + .expect("metadata must survive a missing estimate"); + let error = std::fs::read_to_string(path).unwrap(); + 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 @@ -9872,11 +11359,13 @@ 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) } const TWO_POINT_PROTOCOL: &str = r#" name = "two-point" +version = "test-v2" [defaults] duration_s = 3 @@ -9889,83 +11378,359 @@ frequency_hz = 25.0 depth_a = 0.7 "#; - /// 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 - /// happened to leave them. `I_k` (ū) is the axis the buttons could not - /// sweep at all. + fn saved_protocol_row(folder: &Path, source: &str, index: usize, total: usize) -> PathBuf { + let folder = folder.join("A1-proto"); + std::fs::create_dir_all(&folder).unwrap(); + let stem = format!("A1-proto_row{index}"); + let mut files = toml::map::Map::new(); + for (key, extension) in [ + ("camera_raw", "raw"), + ("camera_config_sidecar", "toml"), + ("photodiode_pdq", "pdq"), + ("photodiode_sidecar", "json"), + ] { + let name = format!("{stem}.{extension}"); + std::fs::write(folder.join(&name), b"saved artifact").unwrap(); + files.insert(key.into(), toml::Value::String(format!(r"C:\old\{name}"))); + } + let path = folder.join(format!("{stem}_config.toml")); + let hash = format!("{:x}", Sha256::digest(source.as_bytes())); + let doc = toml::toml! { + schema = "stage-a.a1.sidecar.v2" + acquisition_complete = true + measurement_id = "A1-proto" + file_stem = stem + [protocol] + source_sha256 = hash + point_index = index + point_total = total + }; + let mut doc = toml::Value::Table(doc); + doc.as_table_mut() + .unwrap() + .insert("files".into(), toml::Value::Table(files)); + std::fs::write(&path, toml::to_string(&doc).unwrap()).unwrap(); + path + } + #[test] - fn a_protocol_commands_all_three_axes_at_every_point() { - let folder = temp_folder("protocol-axes"); - let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + fn protocol_resume_keeps_gaps_and_original_indices() { + let folder = temp_folder("resume-gaps"); + let source = THREE_POINT_PROTOCOL.replace("[0.4, 0.6, 0.8]", "[0.4, 0.4, 0.4, 0.4]"); + let (mut plugin, _) = protocol_plugin(&folder, &source); + saved_protocol_row(&folder, &source, 1, 4); + saved_protocol_row(&folder, &source, 3, 4); let mut sink = ControlSink::default(); + plugin.begin_protocol(&mut sink); + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.index, 1); + assert_eq!(run.remaining_seconds(), 6.0); + assert_eq!(run.reused.iter().copied().collect::>(), vec![0, 2]); + plugin.advance_protocol(&mut sink); + assert_eq!(plugin.protocol.as_ref().unwrap().index, 3); + assert!(sink.services.iter().any(|request| matches!( + modulation_command(request), + Some(ModulationCommandV1::SetOperatingPoint { .. }) + ))); + let _ = std::fs::remove_dir_all(folder); + } - control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); - let lease_req = sink - .services - .iter() - .find_map(|request| match modulation_command(request) { - Some(ModulationCommandV1::AcquireLease { .. }) => Some(request.request_id), - _ => None, - }) - .expect("the protocol takes a modulation lease"); - - sink.services.clear(); - control_tick( - &mut plugin, - inbox_with(vec![accepted(lease_req)]), - &mut sink, - ); - - let commands: Vec = sink - .services - .iter() - .filter_map(modulation_command) - .collect(); - assert!( - commands.iter().any(|command| matches!( - command, - ModulationCommandV1::SetOperatingPoint { mean_u_milli: 400 } - )), - "the I_k axis was not commanded: {commands:?}" - ); + #[test] + fn protocol_resume_all_done_needs_no_hardware() { + let folder = temp_folder("resume-done"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + for index in 1..=2 { + saved_protocol_row(&folder, TWO_POINT_PROTOCOL, index, 2); + } + plugin.modulation = None; + plugin.photodiode = None; + let mut sink = ControlSink::default(); + plugin.begin_protocol(&mut sink); + assert!(plugin.protocol.is_none()); assert!( - commands.iter().any(|command| matches!( - command, - ModulationCommandV1::SetDriveFrequency { - frequency_millihz: 25_000 - } - )), - "the frequency axis was not commanded: {commands:?}" + plugin.message.contains("2 reused, 0 new, 0 missing"), + "{}", + plugin.message ); + assert!(sink.hosts.is_empty() && sink.services.is_empty()); + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn protocol_resume_requires_exact_protocol_and_complete_local_evidence() { + let folder = temp_folder("resume-evidence"); + let path = saved_protocol_row(&folder, TWO_POINT_PROTOCOL, 1, 2); + let original = std::fs::read_to_string(&path).unwrap(); + let hash = format!("{:x}", Sha256::digest(TWO_POINT_PROTOCOL.as_bytes())); + let scan = + || completed_protocol_rows(&folder.join("A1-proto"), "A1-proto", &hash, 2).unwrap(); + assert_eq!(scan().len(), 1); assert!( - commands.iter().any(|command| matches!( - command, - ModulationCommandV1::SetOpticalDepth { depth_a_milli: 700 } - )), - "the depth axis was not commanded: {commands:?}" + completed_protocol_rows(&folder.join("A1-proto"), "A1-proto", "changed", 2) + .unwrap() + .is_empty() + ); + for replacement in [ + "acquisition_complete = false", + "", + "acquisition_complete = true\nacquisition_failure = \"interrupted\"", + ] { + std::fs::write( + &path, + original.replace("acquisition_complete = true", replacement), + ) + .unwrap(); + assert!(scan().is_empty()); + } + std::fs::write(&path, "malformed [").unwrap(); + assert!(scan().is_empty()); + std::fs::write(&path, &original).unwrap(); + for extension in ["raw", "toml", "pdq", "json"] { + let artifact = folder.join(format!("A1-proto/A1-proto_row1.{extension}")); + std::fs::write(&artifact, b"").unwrap(); + assert!(scan().is_empty()); + std::fs::remove_file(&artifact).unwrap(); + assert!(scan().is_empty()); + std::fs::write(&artifact, b"saved artifact").unwrap(); + } + let _ = std::fs::remove_dir_all(folder); + } + + #[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); } - /// Recording before every axis has been acknowledged would file the run - /// under parameters the bench was not actually at. + const THREE_POINT_PROTOCOL: &str = r#" +name = "three-point" +version = "test-v2" + +[defaults] +duration_s = 3 +settle_s = 0.0 + +[[block]] +name = "triple" +mean_u = [0.4, 0.6, 0.8] +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 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::CameraConfigurationApplied { + snapshot, + provenance, + 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 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, + 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, + erc_enabled: Some(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_point_waits_for_all_three_retargets_before_recording() { - let folder = temp_folder("protocol-wait"); - let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + 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(); + 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)]), &mut sink, ); - let retargets: Vec = sink .services .iter() @@ -9981,41 +11746,785 @@ depth_a = 0.7 }) .map(|request| request.request_id) .collect(); - assert_eq!(retargets.len(), 3); + let point_request = sink + .hosts + .iter() + .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 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; - // Two of three applied: still not recording. - sink.services.clear(); control_tick( &mut plugin, - inbox_with(vec![accepted(retargets[0]), accepted(retargets[1])]), + 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 with a retarget still outstanding" + "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, - inbox_with(vec![accepted(retargets[2])]), + PluginControlInbox { + 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, ); control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); assert!( plugin.recording.is_active(), - "the point never started recording; message: {}", - plugin.message + "confirmed point did not start" + ); + let metadata = plugin.recording_metadata(); + assert_eq!( + metadata.get("requested_diff_on").map(String::as_str), + Some("12") ); + 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(); + plugin.recording_completed_ok = true; + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration) + )); let _ = std::fs::remove_dir_all(&folder); } - /// A refused point is the common case in a long survey — a `ū`/`a` pair - /// that runs off the top of the lobe. It must cost that point and carry the - /// owner's own wording, not abandon the rest of the night's work. #[test] - fn a_refused_point_is_skipped_with_the_owners_reason_and_the_run_continues() { - let folder = temp_folder("protocol-skip"); + 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); + 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!( + 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 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 = None; + plugin.record_sensor_telemetry = false; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + 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); + } + + #[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"); + 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 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)]), + &mut sink, + ); + let bias_req = sink + .hosts + .iter() + .rev() + .find(|request| { + matches!( + &request.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { .. } + } + ) + }) + .expect("point configuration 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::RestoreCameraConfiguration) + )); + 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 = None; + plugin.record_sensor_telemetry = false; + 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 { + configuration: CameraConfigurationSourceV1::NamedProfile { name } + } 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); + } + + #[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_some()); + assert!(plugin.message.contains("ERROR"), "{}", plugin.message); + assert!( + !plugin.message.ends_with("pre-run camera settings restored"), + "{}", + plugin.message + ); + + let hosts_before = sink.hosts.len(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!( + sink.hosts.len(), + hosts_before, + "must wait for explicit retry" + ); + plugin.request_stop(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(sink.hosts.len(), hosts_before + 1); + let request_id = sink.hosts.last().unwrap().request_id; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id, + outcome: HostCommandOutcome::CameraConfigurationRestored { + readback: fresh_bias_sensor(105, 98).bias_codes.expect("biases"), + readback_age_s: 0.05, + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(plugin.protocol.is_none()); + let _ = std::fs::remove_dir_all(&folder); + } + + /// The bench cost of finding this out at the twelfth point instead of at + /// the press: eleven recordings whose sidecar the run then refused to write. + #[test] + fn protocol_refuses_a_direct_placement_without_a_lamp_off_dark_reference() { + let folder = temp_folder("protocol-direct-dark"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.photodiode.as_mut().unwrap().placement = + stage_a_plugin_contract::PhotodiodePlacementV1::CameraPath; + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(plugin.protocol.is_none(), "{}", plugin.message); + assert!( + plugin.message.contains("lamp-off dark reference"), + "{}", + plugin.message + ); + assert!(plugin.message.contains("camera path"), "{}", plugin.message); + + // The same bench with the reference captured runs the file. + plugin.protocol_pending = true; + plugin.photodiode.as_mut().unwrap().dark_reference = + Some(stage_a_plugin_contract::PhotodiodeDarkReferenceV1 { + dark_id: "dark-1".into(), + source: stage_a_plugin_contract::PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: 0.01, + captured_at_unix_ms: now_unix_ms(), + age_s: 0.5, + }); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(plugin.protocol.is_some(), "{}", plugin.message); + let _ = std::fs::remove_dir_all(folder); + } + + /// The photodiode's refusal for a bench with no phase-0 markers sends the + /// operator to check the controller's mode, so the panel has to render it. + #[test] + fn a_controller_outside_a1_mode_is_named_on_the_panel() { + let mut plugin = plugin_with_markers(); + plugin.modulation = Some(ModulationStateV1 { + controller_mode: Some("A2".into()), + ..connected_modulation() + }); + let status = |plugin: &StageAA1Plugin| { + plugin + .status_entries() + .iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" | ") + }; + let shown = status(&plugin); + assert!(shown.contains("mode=A2"), "{shown}"); + + // Silent on a correct bench: a line that is always there is not read. + plugin.modulation.as_mut().unwrap().controller_mode = Some("A1".into()); + let shown = status(&plugin); + assert!(!shown.contains("Controller: mode="), "{shown}"); + } + + /// The controller only stamps the phase-0 markers the photodiode measures + /// `a` from while it is in A1 mode, and nothing put the mode back after A2 + /// work — a survey that followed an A2 session measured nothing at all. + #[test] + fn a_protocol_puts_a_controller_left_in_a2_back_into_a1_mode() { + let folder = temp_folder("protocol-prepare-a1"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.modulation.as_mut().unwrap().controller_mode = Some("A2".into()); + 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 prepare_at = sink + .services + .iter() + .position(|request| { + matches!( + modulation_command(request), + Some(ModulationCommandV1::PrepareA1) + ) + }) + .expect("the run states the controller mode"); + assert!(!sink.services.iter().any(|request| matches!( + modulation_command(request), + Some(ModulationCommandV1::SetOperatingPoint { .. }) + ))); + + // A controller that refuses the mode ends the run rather than + // measuring against a trigger nothing is driving. + let prepare_req = sink.services[prepare_at].request_id; + control_tick( + &mut plugin, + inbox_with(vec![rejected(prepare_req, "STATE stop_before_config")]), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + plugin.message.contains("refused A1 mode"), + "{}", + plugin.message + ); + let _ = std::fs::remove_dir_all(folder); + } + + /// A controller already in A1 must not be reconfigured: `CONFIG` is refused + /// while the acquisition runs, so the mode change would stop and restart + /// the photodiode stream for nothing. + #[test] + fn a_controller_already_in_a1_is_left_alone() { + let folder = temp_folder("protocol-prepare-skip"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + 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, + ); + assert!( + !sink.services.iter().any(|request| matches!( + modulation_command(request), + Some(ModulationCommandV1::PrepareA1) + )), + "the mode was already A1" + ); + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn protocol_repeats_a_failed_point_before_it_counts_as_lost() { + let folder = temp_folder("protocol-point-retry"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + plugin.protocol.as_mut().unwrap().phase = ProtocolPhase::Recording; + plugin.message = "Camera recording rejected (recording_start_failed): timeout".into(); + plugin.recover_protocol_recording(&mut sink, now_unix_ms()); + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.index, 0, "the same point is repeated"); + assert!(run.failed.is_empty()); + assert_eq!(run.point_retries, 1); + assert_eq!(run.phase, ProtocolPhase::Settling); + let before = sink.hosts.len(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(sink.hosts.len(), before, "backoff must not start early"); + plugin.protocol.as_mut().unwrap().phase = ProtocolPhase::Recording; + plugin.recording_completed_ok = true; + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.index, 1); + assert_eq!(run.recorded, 1); + assert_eq!(run.point_retries, 0); + assert_eq!(run.consecutive_failures, 0); + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn protocol_keeps_running_after_one_point_is_lost() { + let folder = temp_folder("protocol-point-lost"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + for delay in POINT_RETRY_DELAYS_MS { + plugin.message = "the photodiode saw no trigger markers".into(); + plugin.recover_protocol_recording(&mut sink, 100); + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.index, 0); + assert!(run.failed.is_empty()); + assert_eq!(run.settle_until_ms, 100 + delay); + } + // The retries are used up: the point is lost, the survey goes on. + plugin.recover_protocol_recording(&mut sink, 100); + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.index, 1); + assert_eq!(run.failed.len(), 1); + assert_eq!(run.consecutive_failures, 1); + assert_eq!(run.point_retries, 0); + let _ = std::fs::remove_dir_all(folder); + } + + /// Retrying forever would fill a file with empty points; the run ends once + /// the bench, not the point, is what failed. + #[test] + fn protocol_stops_once_points_keep_failing() { + let folder = temp_folder("protocol-points-keep-failing"); + let (mut plugin, _) = protocol_plugin(&folder, THREE_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + plugin.protocol.as_mut().unwrap().camera_session_active = true; + for _ in 0..MAX_CONSECUTIVE_FAILED_POINTS { + plugin.protocol.as_mut().unwrap().point_retries = POINT_RETRY_DELAYS_MS.len(); + plugin.message = "the photodiode never finalized its PDQ".into(); + plugin.recover_protocol_recording(&mut sink, 100); + } + let run = plugin.protocol.as_ref().unwrap(); + assert_eq!(run.failed.len(), MAX_CONSECUTIVE_FAILED_POINTS); + assert_eq!(run.phase, ProtocolPhase::RestoringCamera); + let closing = run.finish_message.clone().unwrap_or_default(); + assert!(closing.contains("in a row failed"), "{closing}"); + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn protocol_stop_during_point_backoff_does_not_start_another_recording() { + let folder = temp_folder("protocol-stop-backoff"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + plugin.recover_protocol_recording(&mut sink, now_unix_ms()); + let before = sink.hosts.len(); + plugin.request_stop(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(sink.hosts.len(), before); + assert!(plugin.protocol.is_none()); + assert!(!plugin.recording.is_active()); + 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 + /// happened to leave them. `I_k` (ū) is the axis the buttons could not + /// sweep at all. + #[test] + fn a_protocol_commands_all_three_axes_at_every_point() { + let folder = temp_folder("protocol-axes"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = sink + .services + .iter() + .find_map(|request| match modulation_command(request) { + Some(ModulationCommandV1::AcquireLease { .. }) => Some(request.request_id), + _ => None, + }) + .expect("the protocol takes a modulation lease"); + + sink.services.clear(); + control_tick( + &mut plugin, + inbox_with(vec![accepted(lease_req)]), + &mut sink, + ); + + let commands: Vec = sink + .services + .iter() + .filter_map(modulation_command) + .collect(); + assert!( + commands.iter().any(|command| matches!( + command, + ModulationCommandV1::SetOperatingPoint { mean_u_milli: 400 } + )), + "the I_k axis was not commanded: {commands:?}" + ); + assert!( + commands.iter().any(|command| matches!( + command, + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: 25_000 + } + )), + "the frequency axis was not commanded: {commands:?}" + ); + assert!( + commands.iter().any(|command| matches!( + command, + ModulationCommandV1::SetOpticalDepth { depth_a_milli: 700 } + )), + "the depth axis was not commanded: {commands:?}" + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// Recording before every axis has been acknowledged would file the run + /// under parameters the bench was not actually at. + #[test] + fn a_protocol_point_waits_for_all_three_retargets_before_recording() { + let folder = temp_folder("protocol-wait"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + 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(); + assert_eq!(retargets.len(), 3); + + // Two of three applied: still not recording. + sink.services.clear(); + control_tick( + &mut plugin, + inbox_with(vec![accepted(retargets[0]), accepted(retargets[1])]), + &mut sink, + ); + assert!( + !plugin.recording.is_active(), + "recording started with a retarget still outstanding" + ); + + control_tick( + &mut plugin, + inbox_with(vec![accepted(retargets[2])]), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + plugin.recording.is_active(), + "the point never started recording; message: {}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// A refused point is the common case in a long survey — a `ū`/`a` pair + /// that runs off the top of the lobe. It must cost that point and carry the + /// owner's own wording, not abandon the rest of the night's work. + #[test] + fn a_refused_point_is_skipped_with_the_owners_reason_and_the_run_continues() { + let folder = temp_folder("protocol-skip"); let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); let mut sink = ControlSink::default(); @@ -10109,6 +12618,152 @@ depth_a = 99.0 let _ = std::fs::remove_dir_all(&folder); } + /// The owners cap the lease TTL they grant far below the length of a + /// survey, so a run that renewed only when it stepped to its next point + /// lost the drive in the middle of any point longer than that cap — the + /// owner STOPs and switches the output off, which took the phase-0 trigger + /// and the photodiode's optical summary with it. + #[test] + fn a_long_point_renews_the_modulation_lease_before_the_owner_drops_it() { + let folder = temp_folder("protocol-lease-heartbeat"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = sink.services[0].request_id; + let lease_id = plugin + .protocol + .as_ref() + .expect("the protocol is running") + .lease_id + .clone(); + sink.services.clear(); + control_tick( + &mut plugin, + inbox_with(vec![accepted(lease_req)]), + &mut sink, + ); + + // The owner granted far less than the whole-survey TTL that was asked + // for, and the point is still running. + let now_ms = now_unix_ms(); + if let Some(state) = plugin.modulation.as_mut() { + state.lease = Some(LeaseSnapshotV1 { + lease_id: lease_id.clone(), + holder: ClientId::new(A1_PLUGIN_ID), + expires_at_unix_ms: now_ms + LEASE_RENEW_MARGIN_MS / 2, + run_id: None, + }); + } + sink.services.clear(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + let renewed = sink + .services + .iter() + .filter_map(modulation_command) + .any(|command| matches!(command, ModulationCommandV1::RenewLease { .. })); + assert!( + renewed, + "the lease was left to expire underneath the point: {:?}", + sink.services + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// A lease with plenty of time left must not be renewed on every tick: the + /// control plane runs at 20 Hz and each renewal is a device round trip. + #[test] + fn a_lease_with_time_left_is_not_renewed_every_tick() { + let folder = temp_folder("protocol-lease-quiet"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = sink.services[0].request_id; + let lease_id = plugin + .protocol + .as_ref() + .expect("the protocol is running") + .lease_id + .clone(); + control_tick( + &mut plugin, + inbox_with(vec![accepted(lease_req)]), + &mut sink, + ); + if let Some(state) = plugin.modulation.as_mut() { + state.lease = Some(LeaseSnapshotV1 { + lease_id, + holder: ClientId::new(A1_PLUGIN_ID), + expires_at_unix_ms: now_unix_ms() + LEASE_RENEW_MARGIN_MS * 4, + run_id: None, + }); + } + + sink.services.clear(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!( + !sink + .services + .iter() + .filter_map(modulation_command) + .any(|command| matches!(command, ModulationCommandV1::RenewLease { .. })), + "a lease that is nowhere near expiry was renewed anyway: {:?}", + sink.services + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// Starting and stopping the host recorder is reported as `SourceChanged`, + /// twice per recording. Between two points a protocol is not "recording", + /// so asking only about the recording treated its own self-inflicted + /// boundary as an idle-time reset and wiped the survey's pilot windows, + /// background floor and response curve mid-run. + #[test] + fn a_source_change_between_two_protocol_points_keeps_the_survey_state() { + let folder = temp_folder("protocol-discontinuity"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + 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, + ); + plugin.background_floor = Some((0.25, 0.25)); + plugin.pilot_windows = Some(( + PhaseWindow { + start: 0.1, + end: 0.2, + }, + PhaseWindow { + start: 0.6, + end: 0.7, + }, + )); + assert!(!plugin.recording.is_active(), "the point is between stages"); + + plugin.on_discontinuity(PluginDiscontinuity::SourceChanged); + + assert_eq!( + plugin.background_floor, + Some((0.25, 0.25)), + "the survey's background reference was wiped between two points" + ); + assert!( + plugin.pilot_windows.is_some(), + "the survey's pilot windows were wiped between two points" + ); + + let _ = std::fs::remove_dir_all(&folder); + } + /// Each point's own duration governs the recording, not the panel's — a /// survey whose lengths silently came from the UI would not be /// reproducible from the protocol alone. @@ -10210,6 +12865,61 @@ bias_refr_code,status,error\n\ assert_eq!(parsed["channels"]["temperature_c"]["value"][0], 41.5); // The wide original does not stay behind in the capture folder. assert!(!capture.join("host-capture.sensor-monitoring.csv").exists()); + assert!( + !plugin.last_run_had_no_readout, + "a run that did write a readout must not warn about one" + ); + + let _ = std::fs::remove_dir_all(&capture); + let _ = std::fs::remove_dir_all(&output); + } + + /// The host's telemetry companion is governed by its own **Record sensor + /// monitoring** switch, which is off by default and which A1 cannot ask + /// about. A survey that recorded forty runs and kept the bench conditions + /// of none of them used to say nothing at all — the absence surfaced in + /// the analysis, months later. + #[test] + fn a_run_with_no_host_telemetry_says_so_in_the_panel() { + let capture = temp_folder("sensor-off-capture"); + std::fs::create_dir_all(&capture).expect("capture dir"); + let raw = capture.join("host-capture.raw"); + std::fs::write(&raw, b"raw").expect("raw"); + // No `.sensor-monitoring.csv` beside it: the host switch was off. + + let output = temp_folder("sensor-off-output"); + let mut plugin = plugin_with_markers(); + plugin.sensor = Some(SensorMonitoringV1 { + temperature_c: Some(21.5), + pixel_dead_time_us: Some(18.1), + illumination_lux: Some(0.07), + ..SensorMonitoringV1::default() + }); + plugin.output_folder = output.display().to_string(); + plugin.recording.folder = output.display().to_string(); + plugin.recording.id = "A1-sensor-off".into(); + plugin.recording.stem = "A1-sensor-off_20260803-120000".into(); + plugin.recording.cam_finalized_path = Some(raw.display().to_string()); + + plugin.gather_into_measurement_folder(); + + assert!( + plugin.recording.sensor_readout_path.is_none(), + "there was no telemetry to compact" + ); + let panel = plugin + .status_entries() + .into_iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + assert!( + panel.contains("Record sensor monitoring"), + "the panel does not name the switch that governs the readout:\n{panel}" + ); let _ = std::fs::remove_dir_all(&capture); let _ = std::fs::remove_dir_all(&output); @@ -10232,14 +12942,65 @@ 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\"")); + // Compared against the same derivation, not a literal: `Path::join` + // renders the platform's separator, and the bench runs on Windows. + let parsed: toml::Value = toml::from_str(&text).expect("the sidecar is valid TOML"); + assert_eq!( + parsed["files"]["camera_config_sidecar"].as_str(), + sibling_toml("/data/A1-test/A1-test.raw").as_deref(), + ); 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())); @@ -10323,6 +13084,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. /// @@ -10836,4 +13619,181 @@ bias_refr_code,status,error\n\ assert!(plugin.response_points.is_empty()); assert!(plugin.pilot_windows.is_none()); } + #[test] + fn protocol_restarts_an_a1_acquisition_stopped_by_a2() { + let folder = temp_folder("a1-stopped-after-a2"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.modulation.as_mut().unwrap().controller_state = + stage_a_plugin_contract::ControllerStateV1::SafeIdle; + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease = sink.services[0].request_id; + sink.services.clear(); + control_tick(&mut plugin, inbox_with(vec![accepted(lease)]), &mut sink); + let prepare = sink + .services + .iter() + .find(|request| { + matches!( + modulation_command(request), + Some(ModulationCommandV1::PrepareA1) + ) + }) + .unwrap(); + let request: ModulationRequestV1 = serde_json::from_value(prepare.payload.clone()).unwrap(); + assert!(request.requested_revision.is_some()); + assert!( + sink.hosts.is_empty(), + "no camera capture before preparation" + ); + } + + /// A2's drive-synchronized capture configures the controller *in A1 mode*, + /// at A2's own sample rate. A run that read the mode string alone found + /// "A1, running" and prepared nothing, so it measured against whatever A2 + /// had left behind. The A2 target the owner still carries says otherwise. + #[test] + fn protocol_prepares_after_a_drive_synchronized_a2_capture() { + let folder = temp_folder("a1-after-a2-drive-sync"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let modulation = plugin.modulation.as_mut().unwrap(); + // Exactly what such a capture leaves: A1 mode, a running acquisition, + // and an A2 target. + modulation.controller_mode = Some("A1".into()); + modulation.controller_state = stage_a_plugin_contract::ControllerStateV1::Running; + modulation.acknowledged = Some(stage_a_plugin_contract::ModulationTargetV1 { + a2_configuration: Some(stage_a_plugin_contract::A2AcquisitionConfigV1 { + timing_reference: stage_a_plugin_contract::A2TimingReferenceV1::DriveSync, + mean_u_milli: 400, + depth_a_milli: 700, + frequency_millihz: 500, + min_half_us: 100_000, + v_null_dac: 100, + v_peak_dac: 1_000, + comparator_threshold_dac: 0, + comparator_hysteresis: 1, + comparator_invert: false, + sample_rate_hz: 20_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + }), + ..acknowledged_sine(25.0) + }); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease = sink.services[0].request_id; + sink.services.clear(); + control_tick(&mut plugin, inbox_with(vec![accepted(lease)]), &mut sink); + assert!( + sink.services.iter().any(|request| matches!( + modulation_command(request), + Some(ModulationCommandV1::PrepareA1) + )), + "an A2 target is not an A1 acquisition" + ); + assert!( + sink.hosts.is_empty(), + "no camera capture before preparation" + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn an_in_progress_drive_reply_cannot_start_recording() { + let folder = temp_folder("drive-not-yet-applied"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease = sink.services[0].request_id; + control_tick(&mut plugin, inbox_with(vec![accepted(lease)]), &mut sink); + let request_id = plugin.protocol.as_ref().unwrap().pending_reqs[0]; + let mut reply = accepted(request_id); + let response = ModulationResponseV1 { + marker_diagnostics: None, + common: stage_a_plugin_contract::ResponseCommonV1 { + contract_version: 1, + request_id: RequestId(request_id), + owner_instance: OwnerInstanceId::new("test"), + run_id: None, + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::InProgress, + completed_at_unix_ms: None, + error: None, + }, + controller_state: stage_a_plugin_contract::ControllerStateV1::Running, + acknowledged_target: None, + }; + reply.outcome = PluginServiceOutcome::Accepted { + payload: serde_json::to_value(response).unwrap(), + }; + plugin.on_service_reply(&reply); + assert!(plugin + .protocol + .as_ref() + .unwrap() + .pending_reqs + .contains(&request_id)); + plugin.poll_modulation_requests(&mut sink); + assert!( + sink.services + .iter() + .filter(|r| r.request_id == request_id) + .count() + >= 2 + ); + } + #[test] + fn metadata_write_failure_cannot_count_as_a_completed_point() { + let folder = temp_folder("metadata-disk-failure"); + std::fs::create_dir_all(&folder).unwrap(); + let mut plugin = plugin_locking(1.0, &folder); + let blocked = folder.join("not-a-directory"); + std::fs::write(&blocked, b"blocked").unwrap(); + plugin.recording.folder = blocked.display().to_string(); + plugin.recording.id = "test".into(); + plugin.recording.cam_complete = true; + plugin.recording.pd_finalized = true; + plugin.recording.pd_valid = true; + plugin.recording.pd_pdq_path = Some("missing.pdq".into()); + plugin.recording.pd_sidecar_path = Some("missing.json".into()); + plugin.finish_recording(&mut ControlSink::default()); + assert!(!plugin.recording_completed_ok); + assert!(plugin.message.contains("metadata save failed")); + } + + #[test] + fn a_skipped_point_leaves_its_reason_on_disk() { + let folder = temp_folder("persistent-failure"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + control_tick( + &mut plugin, + PluginControlInbox::default(), + &mut ControlSink::default(), + ); + let run = plugin.protocol.as_ref().unwrap(); + let path = folder.join(format!( + "{}_progress.jsonl", + sanitize_stem(run.lease_id.as_str()) + )); + plugin.fail_protocol_point( + &mut ControlSink::default(), + "controller refused the requested frequency".into(), + ); + let text = std::fs::read_to_string(path).unwrap(); + let entries: Vec = text + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let failed = entries + .iter() + .find(|entry| entry["event"] == "point_failed") + .unwrap(); + assert_eq!(failed["point_index"], 1); + assert_eq!( + failed["detail"], + "controller refused the requested frequency" + ); + } } diff --git a/plugins/stage-a-a2/Cargo.toml b/plugins/stage-a-a2/Cargo.toml new file mode 100644 index 0000000..8010412 --- /dev/null +++ b/plugins/stage-a-a2/Cargo.toml @@ -0,0 +1,22 @@ +[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" +tempfile = "3" + +[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..2a15151 --- /dev/null +++ b/plugins/stage-a-a2/README.md @@ -0,0 +1,49 @@ +# Stage-A A2 latency runner + +Record camera RAW and continuous photodiode PDQ with a common digital timing anchor; +measure optical t50, contrast and latency offline. + +1. Install the matching plugin release bundles and production Teensy firmware. +2. Connect camera EXT_TRIGGER to **J24 phase-zero sync** for these protocols. +3. Apply the optical calibration in the modulation owner. Configure the emission + PD, reference set and output directory in the photodiode owner. Enable camera + trigger recording and monitoring, and disable STC/Trail/ERC. +4. Load `protocols/a2_drive_sync_smoke.toml` (37 s plus pauses). +5. After checking its finalized files and common timing anchors, load + `protocols/a2_production_drive_sync.toml` (43 points, 122 min 17 s plus overhead). + +The runner sets every point itself. Pauses explicitly ask to block/open the path; +`blocked_drive_sham` keeps it blocked while the electrical drive runs. +The production protocol has no online PD-amplitude threshold. Missing/corrupt data +still stop the acquisition; timing warnings remain visible for offline review. + +J24 pulse falling is not optical OFF. PDQ stores the digital marker alongside the +analog samples. Fit clock offset/drift, then locate optical ON/OFF in the PD waveform. +A completed protocol does not by itself qualify absolute latency or intrinsic jitter. + +Existing comparator templates retain their strict defaults. See the complete +[feature contract](../../docs/features/stage-a-a2.md) for modes, evidence, reference +reuse, mean-level conventions, firmware installation and remaining hardware checks. + +For the time-limited first laboratory block, load `protocols/a2_core_drive_sync.toml` +(19 points, 19 min 55 s plus overhead). This retains the essential capture controls +but has fewer repeats and does not replace offline timing qualification. + +### Measurement control + +Like A1, select a **Measurement id** (or **New id**), load the protocol, then use +**Run protocol**, **Continue** at a requested pause, and **Stop** when needed. +The id names the folder; subsequent runs use unique filenames. The status shows +completed points and remaining acquisition/settling time, excluding manual pauses, +controller setup and file finalization. All files go below the data folder chosen +in the photodiode plugin. + +This workflow requires the matching Augur host with `StartRecording.root_dir` +support. A2 checks the actual output directory and refuses a split camera/PD run. +After updating, close/reopen the Windows host and verify one short saved pair +before the long protocol. Do not mix the new DLL with an older executable. + +Reuse the same measurement ID and exact protocol to continue only missing rows. +The runner requires explicit completion metadata and the four nonempty recording +files in that ID folder. Older records without completion evidence are not skipped. +The status separates reused/new rows and estimates remaining acquisition time. diff --git a/plugins/stage-a-a2/plugin.toml b/plugins/stage-a-a2/plugin.toml new file mode 100644 index 0000000..e9fe216 --- /dev/null +++ b/plugins/stage-a-a2/plugin.toml @@ -0,0 +1,14 @@ +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", + "apply_camera_configuration", + "restore_camera_configuration", +] diff --git a/plugins/stage-a-a2/protocols/a2_core_drive_sync.toml b/plugins/stage-a-a2/protocols/a2_core_drive_sync.toml new file mode 100644 index 0000000..ad10ea4 --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_core_drive_sync.toml @@ -0,0 +1,211 @@ +# Short, prioritized A2 capture. Prepared 2026-09-07. +# 50 commanded cycles per grid condition; startup/boundary exclusions reduce usable counts. +# Not a precision-equivalent replacement for the full two-pass matrix. +# Camera EXT_TRIGGER must receive J24. Optical t50 and contrast are reviewed offline. +# mean_u is geometric: cycle-mean target / cosh(depth_a/2), rounded to 0.001. +name = "a2-core-drive-sync-20260907" +timing_reference = "drive_sync" +trigger_validation = "offline_review" + +[[point]] +label = "core_floor_pre" +role = "core_floor_pre_shutter_closed" +acquisition_mode = "dark" +duration_s = 30.0 +settle_s = 2.0 +pause_before = true + +[[point]] +label = "blocked_drive_sham" +role = "blocked_drive_sham" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 15 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "core_cadence_2s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 2 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "core_cadence_1s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_cadence_0p5s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 0.5 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_reference_open" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux300_a0280" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.297 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux300_a0450" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux300_a0800" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.278 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_reference_after_300" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux150_a0800" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.139 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux150_a0450" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.146 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux150_a0280" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.149 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_reference_after_150" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux450_a0280" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.446 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux450_a0450" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.439 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_flux450_a0800" +role = "identification_core" +acquisition_mode = "stepped" +mean_u = 0.416 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_reference_after_450" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "core_floor_post" +role = "core_floor_post_shutter_closed" +acquisition_mode = "dark" +duration_s = 30.0 +settle_s = 2.0 +pause_before = true diff --git a/plugins/stage-a-a2/protocols/a2_drive_sync_smoke.toml b/plugins/stage-a-a2/protocols/a2_drive_sync_smoke.toml new file mode 100644 index 0000000..26883ab --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_drive_sync_smoke.toml @@ -0,0 +1,37 @@ +# A2 production capture, prepared 2026-09-06. +# Camera trigger cable must use J24 (digital phase-zero sync), not ACMP output. +# No online PD-amplitude or comparator-threshold qualification. +# mean_u remains the GEOMETRIC pedestal in the A2 contract. +# To match A1 cycle-mean targets, mean_u = target / cosh(depth_a/2), rounded to 0.001. +# Exact observed flux/depth/t50 are determined from each concurrent PDQ offline. +# Both polarities of the narrow J24 pulse refer to phase zero; falling is NOT optical OFF. +name = "a2-drive-sync-smoke" +timing_reference = "drive_sync" +trigger_validation = "offline_review" + +[[point]] +label = "floor_pre" +role = "floor_pre_shutter_closed" +acquisition_mode = "dark" +duration_s = 5.0 +settle_s = 2.0 +pause_before = true + +[[point]] +label = "sync_light" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "floor_post" +role = "floor_post_shutter_closed" +acquisition_mode = "dark" +duration_s = 5.0 +settle_s = 2.0 +pause_before = true diff --git a/plugins/stage-a-a2/protocols/a2_emission_path_technical_smoke.toml b/plugins/stage-a-a2/protocols/a2_emission_path_technical_smoke.toml new file mode 100644 index 0000000..5b50ba4 --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_emission_path_technical_smoke.toml @@ -0,0 +1,34 @@ +# Short end-to-end A2 hardware test. +# +# This file describes only what to record. The runner reads the active camera, +# modulation and photodiode state and writes it automatically into the RAW, +# PDQ and A2 sidecars. The comparator threshold is measured automatically from +# the two photodiode plateaus. +name = "a2-emission-path-technical-smoke" + +[[point]] +label = "technical_floor_pre" +role = "technical_floor_pre_shutter_closed" +acquisition_mode = "dark" +duration_s = 10.0 +settle_s = 2.0 +pause_before = true + +[[point]] +label = "technical_step_1s" +role = "technical_smoke" +acquisition_mode = "stepped" +mean_u = 0.30 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 10 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "technical_floor_post" +role = "technical_floor_post_shutter_closed" +acquisition_mode = "dark" +duration_s = 10.0 +settle_s = 2.0 +pause_before = true 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..b17f7cf --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml @@ -0,0 +1,174 @@ +# Complete A2 point sequence for the current ATTO647 fluorescence chain. +# +# This file describes only what to record. The runner reads camera, +# modulation, photodiode and comparator provenance from their owners and writes +# it into each measurement sidecar. +name = "a2-atto647-fluorescence-chain-followup" + +# 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 +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 +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 + +[[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 +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 + +[[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 +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 +[[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 +[[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 +[[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 +[[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 + +# 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 +[[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 +[[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 + +[[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 + +[[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/protocols/a2_production_drive_sync.toml b/plugins/stage-a-a2/protocols/a2_production_drive_sync.toml new file mode 100644 index 0000000..e320455 --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_production_drive_sync.toml @@ -0,0 +1,477 @@ +# A2 production capture, prepared 2026-09-06. +# Camera trigger cable must use J24 (digital phase-zero sync), not ACMP output. +# No online PD-amplitude or comparator-threshold qualification. +# mean_u remains the GEOMETRIC pedestal in the A2 contract. +# To match A1 cycle-mean targets, mean_u = target / cosh(depth_a/2), rounded to 0.001. +# Exact observed flux/depth/t50 are determined from each concurrent PDQ offline. +# Both polarities of the narrow J24 pulse refer to phase zero; falling is NOT optical OFF. +name = "a2-production-drive-sync-20260907" +timing_reference = "drive_sync" +trigger_validation = "offline_review" + +[[point]] +label = "floor_pre" +role = "floor_pre_shutter_closed" +acquisition_mode = "dark" +duration_s = 30.0 +settle_s = 2.0 +pause_before = true + +[[point]] +label = "blocked_drive_sham" +role = "blocked_drive_sham" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "cadence_2s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 2 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = true + +[[point]] +label = "cadence_1s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "cadence_0p5s" +role = "commissioning" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 0.5 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_open" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux300_a0150" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.299 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux300_a0280" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.297 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux300_a0450" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux300_a0800" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.278 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux300_a1300" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.246 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_a_flux300" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux150_a1300" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.123 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux150_a0800" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.139 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux150_a0450" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.146 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux150_a0280" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.149 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux150_a0150" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.15 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_a_flux150" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux450_a0150" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.449 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux450_a0280" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.446 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux450_a0450" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.439 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux450_a0800" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.416 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_a_flux450_a1300" +role = "identification_pass_a" +acquisition_mode = "stepped" +mean_u = 0.369 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_a_flux450" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux450_a1300" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.369 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux450_a0800" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.416 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux450_a0450" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.439 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux450_a0280" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.446 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux450_a0150" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.449 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_b_flux450" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux150_a0150" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.15 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux150_a0280" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.149 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux150_a0450" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.146 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux150_a0800" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.139 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux150_a1300" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.123 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_b_flux150" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux300_a1300" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.246 +depth_a = 1.3 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux300_a0800" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.278 +depth_a = 0.8 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux300_a0450" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux300_a0280" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.297 +depth_a = 0.28 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "grid_b_flux300_a0150" +role = "identification_pass_b" +acquisition_mode = "stepped" +mean_u = 0.299 +depth_a = 0.15 +half_period_s = 1.0 +transitions_per_polarity = 100 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "reference_after_b_flux300" +role = "reference" +acquisition_mode = "stepped" +mean_u = 0.293 +depth_a = 0.45 +half_period_s = 1.0 +transitions_per_polarity = 50 +settle_s = 3.0 +pause_before = false + +[[point]] +label = "floor_post" +role = "floor_post_shutter_closed" +acquisition_mode = "dark" +duration_s = 30.0 +settle_s = 2.0 +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..752cf8f --- /dev/null +++ b/plugins/stage-a-a2/src/lib.rs @@ -0,0 +1,11 @@ +//! 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 resume; +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..d565e65 --- /dev/null +++ b/plugins/stage-a-a2/src/protocol.rs @@ -0,0 +1,427 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +pub const MAX_POINTS: usize = 4_096; + +/// An A2 protocol describes only the recordings to perform. +/// +/// Camera, modulation, photodiode and comparator provenance belongs to the +/// components that own those values. The runner snapshots those owners and +/// writes their state into each measurement sidecar. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Protocol { + pub name: String, + #[serde(default)] + pub trigger_validation: TriggerValidation, + #[serde(default)] + pub timing_reference: stage_a_plugin_contract::A2TimingReferenceV1, + #[serde(rename = "point")] + pub points: Vec, +} + +/// Noisy captures can be retained for review without declaring timing valid. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TriggerValidation { + #[default] + Strict, + OfflineReview, +} + +/// A2 controller values that are owned by the A2 runner, not by the protocol. +/// They are recorded in the sidecar exactly as sent to the firmware. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ControllerSetup { + pub comparator_hysteresis: u8, + pub comparator_invert: bool, + pub min_half_us: Option, + pub sample_rate_hz: u32, + pub block_samples: u32, +} + +impl Default for ControllerSetup { + fn default() -> Self { + Self { + comparator_hysteresis: 1, + comparator_invert: false, + min_half_us: None, + sample_rate_hz: 500_000, + block_samples: 256, + } + } +} + +/// How a stepped point gets the comparator threshold `V_50`. +/// +/// Protocols normally omit this field. `Auto` then measures both plateaus and +/// selects their midpoint. A frozen code remains an advanced diagnostic escape +/// hatch and is written into provenance as an operator assertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ComparatorThreshold { + #[default] + Auto, + Frozen(u16), +} + +impl ComparatorThreshold { + pub fn frozen_code(self) -> Option { + match self { + Self::Auto => None, + Self::Frozen(code) => Some(code), + } + } + + pub fn is_auto(self) -> bool { + matches!(self, Self::Auto) + } + + pub fn mode(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Frozen(_) => "frozen", + } + } +} + +impl Serialize for ComparatorThreshold { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Auto => serializer.serialize_str("auto"), + Self::Frozen(code) => serializer.serialize_u16(*code), + } + } +} + +impl<'de> Deserialize<'de> for ComparatorThreshold { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Mode(String), + Code(i64), + } + match Raw::deserialize(deserializer)? { + Raw::Mode(mode) => { + if mode.trim().eq_ignore_ascii_case("auto") { + Ok(Self::Auto) + } else { + Err(serde::de::Error::custom(format!( + "comparator_threshold_dac must be \"auto\" or a frozen code in 1..=4095, not {mode:?}" + ))) + } + } + Raw::Code(code) => u16::try_from(code).map(Self::Frozen).map_err(|_| { + serde::de::Error::custom(format!( + "comparator_threshold_dac {code} is outside the frozen code range 1..=4095" + )) + }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Point { + pub label: String, + #[serde(default = "default_role")] + pub role: String, + #[serde(default = "default_settle_seconds")] + pub settle_s: f64, + #[serde(default)] + pub pause_before: bool, + #[serde(flatten)] + pub acquisition: Acquisition, +} + +fn default_role() -> String { + "measurement".into() +} + +fn default_settle_seconds() -> f64 { + 3.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde( + tag = "acquisition_mode", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum Acquisition { + Dark { + duration_s: f64, + }, + Stepped { + mean_u: f64, + depth_a: f64, + half_period_s: f64, + transitions_per_polarity: u32, + #[serde(default)] + comparator_threshold_dac: ComparatorThreshold, + }, +} + +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), + } + } +} + +#[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 {} + +impl Protocol { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.name.trim().is_empty() { + return Err(ProtocolError("protocol name must not be empty".into())); + } + if self.points.is_empty() || self.points.len() > MAX_POINTS { + return Err(ProtocolError(format!( + "protocol must contain 1..={MAX_POINTS} points" + ))); + } + for (index, point) in self.points.iter().enumerate() { + if point.label.trim().is_empty() || point.role.trim().is_empty() { + return Err(ProtocolError(format!( + "point {} needs label and role", + index + 1 + ))); + } + if !point.settle_s.is_finite() || point.settle_s < 0.0 { + return Err(ProtocolError(format!( + "point {} has invalid settle_s", + index + 1 + ))); + } + match point.acquisition { + Acquisition::Dark { duration_s } => { + if !duration_s.is_finite() || 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 !(mean_u.is_finite() + && depth_a.is_finite() + && half_period_s.is_finite() + && 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 let Some(code) = comparator_threshold_dac.frozen_code() { + if !(1..=4_095).contains(&code) { + return Err(ProtocolError(format!( + "point {} comparator_threshold_dac must be \"auto\" or a frozen code in 1..=4095", + index + 1 + ))); + } + } + } + } + } + Ok(()) + } + + pub fn total_seconds(&self) -> f64 { + self.points + .iter() + .map(|point| point.settle_s + point.acquisition_seconds()) + .sum() + } +} + +pub fn parse(text: &str) -> Result { + let protocol: Protocol = + toml::from_str(text).map_err(|error| ProtocolError(error.to_string()))?; + protocol.validate()?; + Ok(protocol) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = r#" +name = "a2-test" + +[[point]] +label = "core" +acquisition_mode = "stepped" +mean_u = 0.3 +depth_a = 0.45 +half_period_s = 0.5 +transitions_per_polarity = 10 +"#; + + #[test] + fn parses_point_only_protocol() { + let protocol = parse(BASE).unwrap(); + assert_eq!(protocol.points.len(), 1); + assert_eq!(protocol.points[0].role, "measurement"); + assert_eq!(protocol.points[0].settle_s, 3.0); + } + + #[test] + fn dark_point_has_duration_but_no_step_parameters() { + let dark = r#" +name = "dark" +[[point]] +label = "floor" +acquisition_mode = "dark" +duration_s = 30 +"#; + let protocol = parse(dark).unwrap(); + assert_eq!(protocol.points[0].acquisition_seconds(), 30.0); + } + + #[test] + fn comparator_threshold_defaults_to_auto() { + let Acquisition::Stepped { + comparator_threshold_dac, + .. + } = parse(BASE).unwrap().points[0].acquisition + else { + panic!("expected a stepped point"); + }; + assert_eq!(comparator_threshold_dac, ComparatorThreshold::Auto); + } + + #[test] + fn invalid_frozen_threshold_refuses() { + let text = BASE.replace( + "transitions_per_polarity = 10", + "transitions_per_polarity = 10\ncomparator_threshold_dac = 0", + ); + assert!(parse(&text).unwrap_err().0.contains("comparator_threshold")); + } + + #[test] + fn old_manual_hardware_sections_are_rejected() { + let text = BASE.replacen( + "[[point]]", + "[gates]\nfirmware_a2_confirmed = true\n\n[[point]]", + 1, + ); + let error = parse(&text).unwrap_err().0; + assert!(error.contains("gates"), "unexpected error: {error}"); + } + + #[test] + fn shipped_technical_smoke_is_a_short_point_only_protocol() { + let text = include_str!("../protocols/a2_emission_path_technical_smoke.toml"); + let protocol = parse(text).unwrap(); + assert_eq!(protocol.points.len(), 3); + assert_eq!(protocol.total_seconds(), 47.0); + } + + #[test] + fn shipped_followup_is_point_only_and_parseable() { + let text = include_str!("../protocols/a2_fluorescence_chain_followup.toml"); + assert!(!parse(text).unwrap().points.is_empty()); + } + #[test] + fn production_drive_sync_schedule_is_valid_and_matches_cycle_mean_targets() { + let plan = parse(include_str!("../protocols/a2_production_drive_sync.toml")).unwrap(); + assert_eq!( + plan.timing_reference, + stage_a_plugin_contract::A2TimingReferenceV1::DriveSync + ); + assert_eq!(plan.trigger_validation, TriggerValidation::OfflineReview); + assert_eq!(plan.points.len(), 43); + assert_eq!(plan.total_seconds(), 7337.0); + let mut seen = std::collections::BTreeMap::new(); + for p in &plan.points { + if let Acquisition::Stepped { + mean_u, depth_a, .. + } = p.acquisition + { + assert!(mean_u * (0.5 * depth_a).exp() <= 1.0); + if p.label.starts_with("grid_") { + let target: f64 = p + .label + .split('_') + .nth(2) + .unwrap() + .trim_start_matches("flux") + .parse::() + .unwrap() + / 1000.0; + assert!((mean_u * (0.5 * depth_a).cosh() - target).abs() < 0.0007); + *seen + .entry(( + (target * 1000.0).round() as u32, + (depth_a * 1000.0).round() as u32, + )) + .or_insert(0) += 1; + } + } + } + assert_eq!(seen.len(), 15); + assert!(seen.values().all(|n| *n == 2)); + let smoke = parse(include_str!("../protocols/a2_drive_sync_smoke.toml")).unwrap(); + assert_eq!(smoke.total_seconds(), 37.0); + } + #[test] + fn short_core_keeps_three_fluxes_three_depths_and_physical_controls() { + let plan = parse(include_str!("../protocols/a2_core_drive_sync.toml")).unwrap(); + assert_eq!(plan.points.len(), 19); + assert_eq!(plan.total_seconds(), 1195.0); + assert_eq!(plan.trigger_validation, TriggerValidation::OfflineReview); + assert_eq!( + plan.timing_reference, + stage_a_plugin_contract::A2TimingReferenceV1::DriveSync + ); + assert_eq!( + plan.points + .iter() + .filter(|p| p.role == "identification_core") + .count(), + 9 + ); + assert_eq!(plan.points.iter().filter(|p| p.pause_before).count(), 4); + assert_eq!( + plan.points + .iter() + .filter(|p| matches!(p.acquisition, Acquisition::Dark { .. })) + .count(), + 2 + ); + assert!(plan.points.iter().any(|p| p.role == "blocked_drive_sham")); + } +} diff --git a/plugins/stage-a-a2/src/resume.rs b/plugins/stage-a-a2/src/resume.rs new file mode 100644 index 0000000..192eecc --- /dev/null +++ b/plugins/stage-a-a2/src/resume.rs @@ -0,0 +1,124 @@ +//! Resume only explicit, finalized acquisitions with all local artifacts present. +use crate::protocol::Protocol; +use serde_json::Value; +use std::{collections::BTreeSet, path::Path}; + +pub(crate) fn completed( + dir: &Path, + id: &str, + hash: &str, + plan: &Protocol, +) -> Result, String> { + let mut found = BTreeSet::new(); + match std::fs::symlink_metadata(dir) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), + Err(e) => return Err(e.to_string()), + Ok(m) if !m.is_dir() => return Err("measurement folder must be a real directory".into()), + Ok(_) => {} + } + for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if !entry.file_type().map_err(|e| e.to_string())?.is_file() + || !entry.file_name().to_string_lossy().ends_with(".a2.json") + { + continue; + } + let bytes = std::fs::read(entry.path()).map_err(|e| e.to_string())?; + let Ok(value) = serde_json::from_slice::(&bytes) else { + continue; + }; + if value["experiment"] != "A2" + || value["measurement_id"] != id + || value["protocol_sha256"] != hash + || value["evidence"]["acquisition_complete"] != true + || !value["evidence"]["failure"].is_null() + { + continue; + } + let Some(index) = value["protocol_row"] + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .and_then(|n| n.checked_sub(1)) + else { + continue; + }; + let Some(point) = plan.points.get(index) else { + continue; + }; + if serde_json::to_value(point).map_err(|e| e.to_string())? != value["point"] { + continue; + } + let fields = [ + ("raw_path", ".raw"), + ("camera_configuration_sidecar_path", ".toml"), + ("pdq_path", ".pdq"), + ("pd_sidecar_path", ".pd.json"), + ]; + if fields.iter().all(|(key, suffix)| { + value["evidence"][key] + .as_str() + .is_some_and(|p| p.ends_with(suffix)) + && local_artifact(dir, &value["evidence"][key]) + }) { + found.insert(index); + } + } + Ok(found) +} + +fn local_artifact(dir: &Path, value: &Value) -> bool { + let Some(path) = value.as_str() else { + return false; + }; + let Some(name) = path + .rsplit(['/', '\\']) + .next() + .filter(|n| !n.is_empty() && *n != "." && *n != "..") + else { + return false; + }; + std::fs::symlink_metadata(dir.join(name)).is_ok_and(|m| m.is_file() && m.len() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn duplicate_conditions_are_distinct_rows_and_partial_records_do_not_count() { + let mut plan = + crate::protocol::parse(include_str!("../protocols/a2_drive_sync_smoke.toml")).unwrap(); + plan.points = vec![plan.points[0].clone(); 3]; + let dir = tempfile::tempdir().unwrap(); + for suffix in ["raw", "toml", "pdq", "pd.json"] { + std::fs::write(dir.path().join(format!("capture.{suffix}")), b"data").unwrap(); + } + let mut value = json!({"experiment":"A2", "measurement_id":"id", "protocol_sha256":"hash", + "protocol_row":2, "point":plan.points[1], "evidence":{ + "acquisition_complete":true, "failure":null, + "raw_path":r"C:\old\capture.raw", "camera_configuration_sidecar_path":"capture.toml", + "pdq_path":"capture.pdq", "pd_sidecar_path":"capture.pd.json"}}); + let path = dir.path().join("capture.a2.json"); + let check = |value: &Value| { + std::fs::write(&path, value.to_string()).unwrap(); + completed(dir.path(), "id", "hash", &plan).unwrap() + }; + assert_eq!(check(&value), BTreeSet::from([1])); + value["evidence"]["acquisition_complete"] = Value::Null; + assert!(check(&value).is_empty()); + value["evidence"]["acquisition_complete"] = Value::Bool(true); + value["evidence"]["failure"] = Value::from("aborted"); + assert!(check(&value).is_empty()); + value["evidence"]["failure"] = Value::Null; + value["protocol_row"] = json!(0); + assert!(check(&value).is_empty()); + value["protocol_row"] = json!(2); + value["point"]["label"] = json!("different"); + assert!(check(&value).is_empty()); + std::fs::write(&path, "truncated{").unwrap(); + assert!(completed(dir.path(), "id", "hash", &plan) + .unwrap() + .is_empty()); + } +} diff --git a/plugins/stage-a-a2/src/runtime.rs b/plugins/stage-a-a2/src/runtime.rs new file mode 100644 index 0000000..9faabd5 --- /dev/null +++ b/plugins/stage-a-a2/src/runtime.rs @@ -0,0 +1,5613 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +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, A2TimingReferenceV1, ClientId, ConnectionStateV1, LeaseId, + ModulationCommandV1, ModulationRequestV1, ModulationResponseV1, ModulationStateV1, + PdqReceiptV1, PdqStartSpecV1, PdqTerminationV1, PhotodiodeCommandV1, PhotodiodeDarkReferenceV1, + PhotodiodeLevelV1, PhotodiodePlacementV1, PhotodiodeRequestV1, PhotodiodeResponseV1, + PhotodiodeSummaryV1, RequestId, RequestOutcomeV1, RunId, SemanticRevision, StreamIntegrityV1, + 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::protocol::{self, Acquisition, ControllerSetup, Point, Protocol, TriggerValidation}; + +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; +/// Conservative fail-closed limit for the first small-ROI A2 runs. The actual +/// peak is written per point; H21 can later lower this bound without changing +/// every protocol file. +const RECORDER_SAFETY_LIMIT_EVENTS_PER_US: u64 = 6; + +/// Settling half-period guard, in microseconds. +/// +/// `min_half_us` has to cover two independent things. The pixel's refractory +/// period `tau_refr` is bounded by `5 * pixel_dead_time_us` from the sensor's +/// own telemetry. The photoreceptor settling time `tau_p` is not reported by any +/// Stage-A telemetry, so this is the conservative bound A1 qualified for it at +/// the dim end of the flux ladder. +/// +/// This is a *floor*. Raising it can only make the runner refuse more +/// protocols, never fewer, which is what makes resolving it safe — guessing a +/// lobe or a threshold would not be. +const SETTLING_GUARD_US: u32 = 1_000; + +/// Millivolts at the top of DAC2's unbuffered output, which drives the +/// comparator's threshold input. +/// +/// Deliberately *not* the photodiode ADC's 3300 mV reference. The two +/// converters have different references over the same 12-bit code range, so an +/// ADC code is never a threshold code; the only honest currency between them is +/// the physical voltage. See `a2_comparator_main.cpp`, +/// `thresholdCodeForMillivolt`. +const THRESHOLD_FULL_SCALE_MILLIVOLT: f64 = 2_500.0; +const THRESHOLD_MAX_CODE: u16 = 4_095; +/// Highest DAC code the stimulus converter accepts. +const STIMULUS_MAX_CODE: u16 = 4_095; + +/// How long a commanded plateau is given to settle optically before its level +/// is even looked at. Mirrors the 200 ms window of the `a` bring-up command, +/// with margin for the owner's own 20 ms level window. +const PLATEAU_SETTLE_MS: u64 = 250; +/// How long after that a level window whose start provably follows the +/// acknowledged drive is waited for, before the point is refused. +const PLATEAU_LEVEL_TIMEOUT_MS: u64 = 5_000; +/// Eight independent owner windows, each at least 20 ms, after settling. +const PLATEAU_WINDOWS: usize = 8; +const MIN_LEVEL_WINDOW_SECONDS: f64 = 0.020; +/// Keep at least two threshold-DAC codes on either side of the midpoint. +const MIN_PLATEAU_SPAN_VOLTS: f64 = 4.0 * 2.5 / 4095.0; +const MAX_PLATEAU_MEAN_SPREAD_FRACTION: f64 = 0.25; + +/// Quantized `(mean_u_milli, depth_a_milli)` pedestal a threshold is measured +/// for. The milli units are exactly what `A2AcquisitionConfigV1` carries to the +/// firmware, so two points that share a key really do share a drive. +type PedestalKey = (u32, u32); + +#[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, + /// Holding the dim plateau of this point's pedestal, awaiting the owner's + /// acknowledgement of the constant drive. + PlateauLowDrive, + /// The dim plateau is commanded; waiting for a photodiode level window that + /// provably began after that acknowledgement. + PlateauLowLevel, + PlateauHighDrive, + PlateauHighLevel, + Prepare, + QuietBeforeCapture, + StartStimulus, + Settle, + StartCamera, + StartPd, + Recording, + StopMod, + FinalizePd, + StopCamera, + ReleasePd, + ReleaseMod, + RestoreCamera, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + Mod, + Pd, + Host, + RenewMod, + RenewPd, +} + +#[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, + pd_sidecar_path: Option, + pdq_sha256: Option, + pdq_marker_counts: Option, + marker_diagnostics_before: Option, + marker_diagnostics_after: Option, + /// `"auto"` or `"frozen"`: whether this point's threshold was measured by + /// the runner or asserted by the protocol. + comparator_threshold_mode: Option<&'static str>, + /// The code actually sent on the `CMP thr=` path for this point. + comparator_threshold_dac: Option, + /// Full plateau evidence when the threshold was measured. Shared by every + /// point of the same pedestal, and recorded on each of them so a single + /// sidecar is self-contained. + threshold_measurement: Option, + acquisition_complete: bool, + valid: bool, + /// Quality concerns do not imply file corruption. Strict protocols stop; + /// diagnostic protocols retain these captures for explicit offline review. + warnings: Vec, + failure: Option, +} + +/// Kind-1 controller values resolved from their owners at preflight, with the +/// provenance a run has to cite precisely because it no longer retypes them. +#[derive(Debug, Clone, PartialEq, Serialize)] +struct ResolvedController { + /// Where the lobe came from. There is only one source; recording it saves a + /// sidecar reader from having to know that. + lobe_source: &'static str, + v_null_dac: u16, + v_peak_dac: u16, + /// Which measured Pockels transfer calibration produced the lobe. + modulation_calibration_id: String, + modulation_owner_instance: String, + min_half_us: u32, + /// `"protocol"` or `"sensor_telemetry"`. + min_half_us_source: &'static str, + pixel_dead_time_us: Option, + refractory_floor_us: u32, + settling_guard_us: u32, +} + +/// The two static levels an A2 log-square alternates between at one pedestal, +/// resolved from the owner's lobe by the same inversion the firmware applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +struct PedestalPlateaus { + mean_u_milli: u32, + depth_a_milli: u32, + low_dac: u16, + high_dac: u16, +} + +/// One commanded plateau and the evidence tying a level window to it. +#[derive(Debug, Clone, Copy)] +struct PlateauStep { + level_dac: u16, + /// Photodiode sample index at the moment the owner acknowledged the drive. + /// A level window is only accepted when it *starts* at or after this. + acknowledged_sample_index: u64, + stream_epoch: u64, + level: Option, + windows: [Option; PLATEAU_WINDOWS], + window_count: usize, +} + +impl PlateauStep { + fn new(level_dac: u16) -> Self { + Self { + level_dac, + acknowledged_sample_index: 0, + stream_epoch: 0, + level: None, + windows: [None; PLATEAU_WINDOWS], + window_count: 0, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct PlateauProbe { + mean_u_milli: u32, + depth_a_milli: u32, + low: PlateauStep, + high: PlateauStep, +} + +/// A measured `V_50` for one pedestal, with the window provenance that proves +/// each plateau was read after its drive was acknowledged. +#[derive(Debug, Clone, PartialEq, Serialize)] +struct MeasuredThreshold { + mean_u_milli: u32, + depth_a_milli: u32, + low_level_dac: u16, + high_level_dac: u16, + low_plateau_volts: f64, + high_plateau_volts: f64, + low_window_peak_to_peak_volts: f64, + high_window_peak_to_peak_volts: f64, + low_windows: Vec, + high_windows: Vec, + low_mean_spread_volts: f64, + high_mean_spread_volts: f64, + mean_difference_standard_error_volts: f64, + noisy_crossing_requires_review: bool, + span_volts: f64, + midpoint_volts: f64, + threshold_dac: u16, + threshold_millivolt: f64, + low_window_sample_count: u64, + low_window_end_sample_index: u64, + low_drive_acknowledged_sample_index: u64, + high_window_sample_count: u64, + high_window_end_sample_index: u64, + high_drive_acknowledged_sample_index: u64, + stream_epoch: u64, + measured_at_unix_ms: u64, +} + +/// PD-owned state frozen when the run starts. This replaces optical and +/// reference IDs copied by hand into the protocol. +#[derive(Debug, Clone, PartialEq, Serialize)] +struct ResolvedPhotodiode { + owner_instance: String, + placement: PhotodiodePlacementV1, + splitter_fraction: Option, + reference_set_id: Option, + load_ohms: Option, + dark_reference: Option, + sample_rate_hz: Option, + stream_epoch: u64, + stream_integrity: StreamIntegrityV1, +} + +struct Run { + protocol: Protocol, + /// Runner-owned settings used for this acquisition. These are not repeated + /// in every point protocol and are written verbatim into the sidecar. + controller: ControllerSetup, + photodiode_setup: ResolvedPhotodiode, + /// Owner-resolved controller values, frozen once at preflight so every + /// point of the run cites the same lobe and the same step floor. + resolved: ResolvedController, + /// Plateau DAC codes per pedestal that needs an auto threshold, computed at + /// preflight so an unreachable plateau refuses before any hardware moves. + pedestals: BTreeMap, + /// Thresholds already measured this run, one per pedestal. + thresholds: BTreeMap, + /// The plateau measurement currently in flight, if any. + probe: Option, + /// Hard deadline for a qualifying level window in the current plateau step. + plateau_timeout_ms: u64, + protocol_path: String, + protocol_sha256: String, + protocol_archive_path: String, + measurement_id: String, + output_root: PathBuf, + attempt_id: String, + completed_points: usize, + resumed_points: BTreeSet, + resume_pause: bool, + pending_mod_request: Option, + last_mod_poll_ms: u64, + index: usize, + phase: Phase, + pending: Option<(PendingKind, u64, u64)>, + lease: LeaseId, + /// Stable run identity bound to both owner leases for the complete protocol. + lease_run_id: String, + /// Per-point identity used for RAW, PDQ and sidecar file names. + 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, + review_points: usize, + cleanup_failures: Vec, + pd_progress: Option<(u64, u64)>, +} + +pub struct StageAA2Plugin { + enabled: bool, + role: PluginRuntimeRole, + output_folder: String, + measurement_id: String, + protocol_path: String, + protocol_preview: Option, + new_id: Press, + 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(), + protocol_preview: None, + new_id: Press::default(), + 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 A2 point 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.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 + .photodiode + .as_ref() + .and_then(|summary| summary.data_dir.as_deref()) + .is_none_or(|folder| folder.trim().is_empty()) + { + return Some("choose the data folder in the photodiode plugin".into()); + } + if self + .modulation + .as_ref() + .is_none_or(|state| state.optical_lobe.is_none()) + { + return Some( + "In the modulation plugin, apply the measured transfer curve with 'Apply to \ + V_null / V_peak'. Do not set mean_u or depth_a there; A2 sets every measurement \ + point from its protocol automatically." + .into(), + ); + } + None + } + + fn begin(&mut self, control: &mut impl Control) { + if self.run.is_some() { + self.message = "A2 refused: an A2 protocol is already running".into(); + 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(folder) = self.photodiode.as_ref().and_then(|pd| pd.data_dir.as_ref()) { + self.output_folder = folder.clone(); + } + let measurement_id = if self.measurement_id.trim().is_empty() { + format!("A2-{}", compact_time()) + } else { + self.measurement_id.trim().to_owned() + }; + if !valid_measurement_id(&measurement_id) { + self.message = "A2 refused: measurement id must use 1–100 letters, digits, '-' or '_' and must not be a reserved Windows filename".into(); + return; + } + let output_root = match resolve_output_root(&self.output_folder) { + Ok(root) => root, + Err(error) => { + self.message = format!("A2 refused: {error}"); + return; + } + }; + self.output_folder = output_root.to_string_lossy().into_owned(); + self.protocol_preview = Some(plan.clone()); + self.measurement_id = measurement_id.clone(); + let hash = hex_hash(text.as_bytes()); + let resumed_points = match crate::resume::completed( + &output_root.join(&measurement_id), + &measurement_id, + &hash, + &plan, + ) { + Ok(points) => points, + Err(error) => { + self.message = format!("A2 refused: cannot inspect existing measurement: {error}"); + return; + } + }; + let Some(index) = (0..plan.points.len()).find(|i| !resumed_points.contains(i)) else { + self.message = format!( + "A2 {}: all {} protocol points already complete; nothing to record", + measurement_id, + plan.points.len() + ); + return; + }; + let attempt_id = compact_time(); + let journal = output_root + .join(&measurement_id) + .join(format!("{attempt_id}_progress.jsonl")); + if journal.exists() { + self.message = + "A2 refused: this run already exists; start again to create a new run".into(); + return; + } + if let Some(blocker) = self.blocker() { + self.message = format!("A2 refused: {blocker}"); + return; + } + // Everything below happens before the camera configuration is applied and + // before either owner lease is acquired: an unresolvable lobe, an + // unresolvable step floor or an unreachable plateau must refuse while + // the bench is still untouched. + let controller = ControllerSetup { + min_half_us: (plan.timing_reference == A2TimingReferenceV1::DriveSync).then_some(0), + ..ControllerSetup::default() + }; + let photodiode_setup = resolve_photodiode( + self.photodiode + .as_ref() + .expect("blocker confirmed the photodiode owner"), + ); + let resolved = match resolve_controller(&controller, self.modulation.as_ref(), self.sensor) + { + Ok(resolved) => resolved, + Err(error) => { + self.message = format!("A2 refused before hardware moved: {error}"); + return; + } + }; + if let Err(error) = check_half_periods(&plan, resolved.min_half_us) { + self.message = format!("A2 refused before hardware moved: {error}"); + return; + } + let pedestals = match plan_pedestals(&plan, &resolved) { + Ok(pedestals) => pedestals, + Err(error) => { + self.message = format!("A2 refused before hardware moved: {error}"); + return; + } + }; + 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, + controller, + photodiode_setup, + resolved, + pedestals, + thresholds: BTreeMap::new(), + probe: None, + plateau_timeout_ms: 0, + protocol_path: self.protocol_path.clone(), + protocol_sha256: hash, + protocol_archive_path, + measurement_id: measurement_id.clone(), + output_root, + attempt_id, + completed_points: 0, + resume_pause: !resumed_points.is_empty(), + resumed_points, + pending_mod_request: None, + last_mod_poll_ms: 0, + index, + phase: Phase::ApplyCamera, + pending: None, + lease: LeaseId::new(format!("a2-{}", now_ms())), + lease_run_id: measurement_id.clone(), + 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(), + review_points: 0, + cleanup_failures: Vec::new(), + pd_progress: None, + }); + if let Err(error) = self.append_progress("run_started") { + self.message = format!("A2 refused: cannot save run progress: {error}"); + self.run = None; + return; + } + self.send_host( + control, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current, + }, + ); + } + + 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 pending_kind = if matches!(command, ModulationCommandV1::RenewLease { .. }) { + PendingKind::RenewMod + } else { + PendingKind::Mod + }; + let request_id = self.next_id(); + let (lease, lease_run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.lease_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(); + e.run_id = Some(RunId::new(lease_run_id)); + if revision { + e.requested_revision = Some(self.next_revision()); + } + let request = 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(), + }; + control.service(&request); + let run = self.run.as_mut().unwrap(); + run.pending_mod_request = Some(request); + run.last_mod_poll_ms = now_ms(); + run.pending = Some((pending_kind, request_id, now_ms())); + } + + fn send_pd( + &mut self, + control: &mut impl Control, + command: PhotodiodeCommandV1, + revision: bool, + ) { + let pending_kind = if matches!(command, PhotodiodeCommandV1::RenewLease { .. }) { + PendingKind::RenewPd + } else { + PendingKind::Pd + }; + let request_id = self.next_id(); + let (lease, lease_run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.lease_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(); + e.run_id = Some(RunId::new(lease_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((pending_kind, 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 = { + let r = self.run.as_ref().unwrap(); + r.protocol.points[r.index].clone() + }; + 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.attempt_id + ); + run.evidence = PointEvidence::default(); + run.last_event_bin_us = None; + run.last_event_bin_count = 0; + run.camera_stop_attempts = 0; + run.probe = None; + if should_pause(&p, self.run.as_ref().unwrap().pause_acknowledged) + || (self.run.as_ref().unwrap().resume_pause + && !self.run.as_ref().unwrap().pause_acknowledged) + { + self.run.as_mut().unwrap().phase = Phase::Paused; + self.message = pause_message(&p); + return; + } + self.run.as_mut().unwrap().resume_pause = false; + match p.acquisition { + Acquisition::Dark { .. } => self.send_mod( + control, + ModulationCommandV1::SafeOff { + reason: "A2 dark acquisition: force modulation safe/off".into(), + }, + true, + ), + Acquisition::Stepped { + mean_u, + depth_a, + comparator_threshold_dac, + .. + } => { + if self.run.as_ref().unwrap().protocol.timing_reference + == A2TimingReferenceV1::DriveSync + { + self.run + .as_mut() + .unwrap() + .evidence + .comparator_threshold_mode = Some("unused_drive_sync"); + self.send_prepare_a2(control, 0); + return; + } + let key = pedestal_key(mean_u, depth_a); + self.run + .as_mut() + .unwrap() + .evidence + .comparator_threshold_mode = Some(comparator_threshold_dac.mode()); + if let Some(code) = comparator_threshold_dac.frozen_code() { + self.send_prepare_a2(control, code); + return; + } + let plateaus = self + .run + .as_ref() + .and_then(|run| run.pedestals.get(&key).copied()); + let Some(plateaus) = plateaus else { + // Preflight computed a plateau pair for every auto point, + // so a miss here means the point set changed under us. + self.fail( + control, + format!( + "no preflight plateau pair for pedestal mean_u={} m, a={} m", + key.0, key.1 + ), + ); + return; + }; + self.start_plateau_probe(control, plateaus); + } + } + } + + /// Sends `PrepareA2` for the current point with an already-decided + /// comparator threshold. The owner turns this into `CFG mode=A2`, + /// `CMP thr=…` and `MOD wave=LOG_SQUARE …`. + fn send_prepare_a2(&mut self, control: &mut impl Control, threshold_dac: u16) { + let (point, controller, resolved) = { + let r = self.run.as_ref().unwrap(); + ( + r.protocol.points[r.index].acquisition.clone(), + r.controller.clone(), + r.resolved.clone(), + ) + }; + let Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + .. + } = point + else { + self.fail( + control, + "internal: PrepareA2 requested for a non-stepped point".into(), + ); + return; + }; + let hz = 1.0 / (2.0 * half_period_s); + let configuration = A2AcquisitionConfigV1 { + timing_reference: self.run.as_ref().unwrap().protocol.timing_reference, + 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: resolved.min_half_us, + v_null_dac: resolved.v_null_dac, + v_peak_dac: resolved.v_peak_dac, + comparator_threshold_dac: threshold_dac, + comparator_hysteresis: controller.comparator_hysteresis, + comparator_invert: controller.comparator_invert, + sample_rate_hz: controller.sample_rate_hz, + block_samples: controller.block_samples, + emit_raw_samples: true, + emit_summary: true, + }; + { + let run = self.run.as_mut().unwrap(); + run.phase = Phase::Prepare; + run.modulation_active = true; + run.probe = None; + run.evidence.expected_triggers_per_polarity = u64::from(transitions_per_polarity); + run.evidence.comparator_threshold_dac = (run.protocol.timing_reference + == A2TimingReferenceV1::Comparator) + .then_some(threshold_dac); + } + self.send_mod( + control, + ModulationCommandV1::PrepareA2 { configuration }, + true, + ); + } + + /// Holds the dim plateau of this pedestal. Mirrors the `a` command of the + /// `a2_comparator` bring-up image, one static level at a time so each + /// plateau gets its own settled level window rather than a min/max over a + /// running square. + fn start_plateau_probe(&mut self, control: &mut impl Control, plateaus: PedestalPlateaus) { + { + let run = self.run.as_mut().unwrap(); + run.probe = Some(PlateauProbe { + mean_u_milli: plateaus.mean_u_milli, + depth_a_milli: plateaus.depth_a_milli, + low: PlateauStep::new(plateaus.low_dac), + high: PlateauStep::new(plateaus.high_dac), + }); + run.phase = Phase::PlateauLowDrive; + run.modulation_active = true; + } + self.message = format!( + "Measuring V50 at mean_u={} m, a={} m: holding the dim plateau (DAC {})", + plateaus.mean_u_milli, plateaus.depth_a_milli, plateaus.low_dac + ); + self.send_mod( + control, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Constant { + level_dac: plateaus.low_dac, + }, + }, + true, + ); + } + + /// The owner acknowledged a constant plateau drive. Records where the + /// photodiode sample clock stood at that moment, so a level window can + /// later be *proven* to have begun after the drive rather than assumed to. + fn plateau_drive_acknowledged(&mut self, control: &mut impl Control, low: bool) { + let observed = self.photodiode.as_ref().and_then(|pd| { + pd.stream + .sample_range + .map(|range| (range.end_sample_index_exclusive, pd.stream.stream_epoch)) + }); + let Some((acknowledged_sample_index, stream_epoch)) = observed else { + self.fail( + control, + "photodiode owner published no sample range, so a plateau level cannot be tied to \ + the acknowledged drive" + .into(), + ); + return; + }; + if self.run.as_ref().is_none_or(|run| run.probe.is_none()) { + self.fail( + control, + "internal: plateau acknowledgement without an active probe".into(), + ); + return; + } + let Some(_rate) = self + .photodiode + .as_ref() + .and_then(|pd| pd.stream.sample_rate_hz) + .filter(|r| *r > 0) + else { + self.fail( + control, + "photodiode sample rate is unavailable during V50 measurement".into(), + ); + return; + }; + let now = now_ms(); + let run = self.run.as_mut().unwrap(); + if let Some(probe) = run.probe.as_mut() { + let step = if low { &mut probe.low } else { &mut probe.high }; + step.acknowledged_sample_index = acknowledged_sample_index; + // The first accepted window must begin after the full settling guard. + step.windows = [None; PLATEAU_WINDOWS]; + step.window_count = 0; + step.stream_epoch = stream_epoch; + } + run.phase = if low { + Phase::PlateauLowLevel + } else { + Phase::PlateauHighLevel + }; + run.deadline_ms = now + PLATEAU_SETTLE_MS; + run.plateau_timeout_ms = now + PLATEAU_SETTLE_MS + PLATEAU_LEVEL_TIMEOUT_MS; + } + + /// Waits for a settled photodiode level whose whole averaging window lies + /// after the acknowledged drive, on the same stream segment. + fn poll_plateau(&mut self, control: &mut impl Control) { + let Some((phase, deadline, timeout)) = self + .run + .as_ref() + .map(|run| (run.phase, run.deadline_ms, run.plateau_timeout_ms)) + else { + return; + }; + let low = phase == Phase::PlateauLowLevel; + let now = now_ms(); + if now < deadline { + return; + } + let step = self.run.as_ref().and_then(|run| run.probe).map(|probe| { + if low { + probe.low + } else { + probe.high + } + }); + let Some(step) = step else { + self.fail( + control, + "internal: plateau level polled without an active probe".into(), + ); + return; + }; + let observed = self + .photodiode + .as_ref() + .map(|pd| (pd.stream.stream_epoch, pd.stream.level)); + let Some((stream_epoch, level)) = observed else { + self.fail( + control, + "photodiode owner state disappeared during the plateau measurement".into(), + ); + return; + }; + if stream_epoch != step.stream_epoch { + self.fail( + control, + format!( + "photodiode stream restarted during the plateau measurement (epoch {} -> \ + {stream_epoch}); the level window cannot be tied to the drive", + step.stream_epoch + ), + ); + return; + } + let rate = self + .photodiode + .as_ref() + .and_then(|pd| pd.stream.sample_rate_hz) + .unwrap_or(0); + let settled_index = step + .acknowledged_sample_index + .saturating_add(u64::from(rate) * PLATEAU_SETTLE_MS / 1000); + let previous_end = step + .windows + .iter() + .flatten() + .last() + .map_or(settled_index, |window| window.end_sample_index); + let qualified = level.filter(|level| { + rate > 0 + && level.sample_count >= (f64::from(rate) * MIN_LEVEL_WINDOW_SECONDS).ceil() as u64 + && level.end_sample_index >= level.sample_count + && level.end_sample_index - level.sample_count >= previous_end + }); + let Some(level) = qualified else { + if now >= timeout { + self.fail( + control, + format!( + "V50 {} plateau at DAC {}: only {}/{} fresh non-overlapping 20 ms windows \ + began after the acknowledged drive and 250 ms settling guard within {} ms; \ + last window end={:?}, required start >= {previous_end}. Check the PD stream for stale or short windows", + if low { "dim" } else { "bright" }, step.level_dac, step.window_count, + PLATEAU_WINDOWS, PLATEAU_LEVEL_TIMEOUT_MS, level.map(|v| v.end_sample_index) + ), + ); + } + return; + }; + if level.clipped { + self.fail( + control, + format!( + "plateau at DAC {} clips the photodiode ADC, so its mean is a truncated \ + estimate and cannot anchor V50", + step.level_dac + ), + ); + return; + } + if !level.mean_volts.is_finite() + || !level.peak_to_peak_volts.is_finite() + || level.peak_to_peak_volts < 0.0 + { + self.fail( + control, + "photodiode plateau contains invalid voltage statistics".into(), + ); + return; + } + let aggregate = { + let probe = self.run.as_mut().unwrap().probe.as_mut().unwrap(); + let step = if low { &mut probe.low } else { &mut probe.high }; + step.windows[step.window_count] = Some(level); + step.window_count += 1; + if step.window_count < PLATEAU_WINDOWS { + return; + } + aggregate_levels(step) + }; + let level = aggregate; + if low { + let high_dac = { + let run = self.run.as_mut().unwrap(); + if let Some(probe) = run.probe.as_mut() { + probe.low.level = Some(level); + } + run.probe.map(|probe| probe.high.level_dac) + }; + let Some(high_dac) = high_dac else { + self.fail( + control, + "internal: plateau probe vanished between levels".into(), + ); + return; + }; + self.run.as_mut().unwrap().phase = Phase::PlateauHighDrive; + self.message = format!("Measuring V50: holding the bright plateau (DAC {high_dac})"); + self.send_mod( + control, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Constant { + level_dac: high_dac, + }, + }, + true, + ); + return; + } + if let Some(probe) = self.run.as_mut().and_then(|run| run.probe.as_mut()) { + probe.high.level = Some(level); + } + self.finish_plateau_probe(control); + } + + /// Turns two settled plateaus into one comparator threshold, or refuses. + fn finish_plateau_probe(&mut self, control: &mut impl Control) { + let probe = self.run.as_ref().and_then(|run| run.probe); + let Some(probe) = probe else { + self.fail( + control, + "internal: plateau probe vanished before its threshold was computed".into(), + ); + return; + }; + let (Some(low), Some(high)) = (probe.low.level, probe.high.level) else { + self.fail( + control, + "internal: plateau probe completed without both levels".into(), + ); + return; + }; + let measured = match measured_threshold(&probe, low, high) { + Ok(measured) => measured, + Err(error) => { + self.fail(control, error); + return; + } + }; + let code = measured.threshold_dac; + if measured.noisy_crossing_requires_review { + self.run.as_mut().unwrap().evidence.warnings.push(format!( + "Photodiode raw noise is large: dim/bright peak-to-peak {:.1}/{:.1} mV, step {:.1} mV. \ + The averaged V50 is resolved; individual trigger timing needs offline review", + measured.low_window_peak_to_peak_volts * 1000.0, + measured.high_window_peak_to_peak_volts * 1000.0, measured.span_volts * 1000.0)); + } + self.message = format!( + "V50 at mean_u={} m, a={} m: {:.1} mV span, threshold DAC {code}", + measured.mean_u_milli, + measured.depth_a_milli, + measured.span_volts * 1000.0 + ); + { + let run = self.run.as_mut().unwrap(); + run.thresholds.insert( + (measured.mean_u_milli, measured.depth_a_milli), + measured.clone(), + ); + run.evidence.threshold_measurement = Some(measured); + } + self.send_prepare_a2(control, code); + } + + 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", "fluorescence_chain".into()), + ("scientific_status", "requires_offline_h4_h5_review".into()), + ("photodiode_timebase", "continuous_dma_no_START".into()), + ( + "sync_onset", + if r.protocol.timing_reference == A2TimingReferenceV1::DriveSync { + "quiet_then_drive_after_both_recorders_open" + } else { + "continuous_comparator" + } + .into(), + ), + ( + "timing_reference", + format!("{:?}", r.protocol.timing_reference), + ), + ( + "pd_marker_sample_index", + if r.evidence + .marker_diagnostics_before + .is_some_and(|d| d.dma_sample_clock) + { + "dma_cursor_v1" + } else { + "unverified_or_foreground_estimate" + } + .into(), + ), + ( + "trigger_validation", + format!("{:?}", r.protocol.trigger_validation), + ), + ( + "photodiode_placement", + photodiode_placement_name(r.photodiode_setup.placement).into(), + ), + // Owner-resolved, not retyped: the run still cites the lobe and the + // step floor it actually ran on. + ("v_null_dac", r.resolved.v_null_dac.to_string()), + ("v_peak_dac", r.resolved.v_peak_dac.to_string()), + ("lobe_source", r.resolved.lobe_source.into()), + ( + "modulation_calibration_id", + r.resolved.modulation_calibration_id.clone(), + ), + ("min_half_us", r.resolved.min_half_us.to_string()), + ("min_half_us_source", r.resolved.min_half_us_source.into()), + ] { + m.insert(k.into(), v); + } + if let Some(fraction) = r.photodiode_setup.splitter_fraction { + m.insert("splitter_fraction_to_pd".into(), fraction.to_string()); + } + if let Some(reference_set_id) = r.photodiode_setup.reference_set_id.as_ref() { + m.insert( + "photodiode_reference_set_id".into(), + reference_set_id.clone(), + ); + } + if let Some(load_ohms) = r.photodiode_setup.load_ohms { + m.insert("photodiode_load_ohms".into(), load_ohms.to_string()); + } + if let Some(reference) = r.photodiode_setup.dark_reference.as_ref() { + m.insert("photodiode_dark_id".into(), reference.dark_id.clone()); + m.insert( + "photodiode_dark_volts".into(), + reference.dark_volts.to_string(), + ); + } + 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_mode", + comparator_threshold_dac.mode().into(), + ), + ] { + m.insert(key.into(), value); + } + // The code that was actually sent on the `CMP thr=` path. Absent + // only if this were ever reached before the threshold resolved, + // which the phase order rules out. + if let Some(code) = r.evidence.comparator_threshold_dac { + m.insert("comparator_threshold_dac".into(), code.to_string()); + } + if let Some(measurement) = r.evidence.threshold_measurement.as_ref() { + for (key, value) in [ + ( + "v50_low_plateau_volts", + measurement.low_plateau_volts.to_string(), + ), + ( + "v50_high_plateau_volts", + measurement.high_plateau_volts.to_string(), + ), + ("v50_span_volts", measurement.span_volts.to_string()), + ( + "v50_low_window_end_sample_index", + measurement.low_window_end_sample_index.to_string(), + ), + ( + "v50_high_window_end_sample_index", + measurement.high_window_end_sample_index.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.completed_points += 1; + r.pause_acknowledged = false; + let next = + (r.index + 1..r.protocol.points.len()).find(|i| !r.resumed_points.contains(i)); + if !r.stop { + if let Some(next) = next { + r.resume_pause = r.protocol.points[r.index + 1..next] + .iter() + .any(|p| p.pause_before); + r.index = next; + } + } + next.is_none() || r.stop + }; + if done { + self.release_next(control); + } else { + self.prepare(control); + } + } + + fn release_next(&mut self, control: &mut impl Control) { + if self.run.as_ref().is_some_and(|r| { + r.abort_reason.is_some() && !r.run_id.is_empty() && r.index < r.protocol.points.len() + }) { + if let Err(error) = self.write_sidecar() { + let run = self.run.as_mut().unwrap(); + let problem = format!("cannot save A2 sidecar: {error}"); + if !run.cleanup_failures.contains(&problem) { + run.cleanup_failures.push(problem); + } + } + } + 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() + .cleanup_failures + .push("camera restore was not confirmed after 3 attempts".into()); + 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) { + if self.run.as_ref().is_some_and(|r| !r.run_id.is_empty()) { + if let Err(error) = self.write_sidecar() { + self.run + .as_mut() + .unwrap() + .abort_reason + .get_or_insert(format!("cannot save final A2 sidecar: {error}")); + } + } + if let Err(error) = self.append_progress("run_finished") { + if let Some(run) = self.run.as_mut() { + run.abort_reason + .get_or_insert(format!("cannot save final progress: {error}")); + } + } + if let Some(run) = self.run.take() { + self.message = if let Some(reason) = run.abort_reason { + format!( + "A2 stopped: {reason}. Files retained in {}/{}", + self.output_folder, run.measurement_id + ) + } else if run.review_points > 0 { + format!("A2 capture finished: {}/{} points recorded; {} point(s) require offline timing review. Files: {}/{}", + run.completed_points + run.resumed_points.len(), run.protocol.points.len(), run.review_points, self.output_folder, run.measurement_id) + } else { + format!("A2 acquisition checks passed; H4/H5 and offline first-event analysis remain required. Files: {}/{}", + self.output_folder, run.measurement_id) + }; + if !run.cleanup_failures.is_empty() { + self.message.push_str(&format!( + "; CLEANUP NOT CONFIRMED: {}", + run.cleanup_failures.join("; ") + )); + } + } + } + + fn fail(&mut self, control: &mut impl Control, reason: String) { + let Some(run) = self.run.as_mut() else { return }; + let reason = format!( + "point {} '{}' ({:?}): {reason}", + run.index + 1, + run.protocol + .points + .get(run.index) + .map_or("cleanup", |p| p.label.as_str()), + run.phase + ); + run.stop = true; + run.evidence.valid = false; + run.evidence.failure.get_or_insert(reason.clone()); + run.abort_reason.get_or_insert(reason); + self.message = format!("A2 stopped: {}", run.abort_reason.as_deref().unwrap()); + run.pending = None; + if run.modulation_active { + run.phase = Phase::StopMod; + self.send_mod( + control, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Off, + }, + 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.run.as_ref().is_some_and(|r| r.stop) { + 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 !run.stop + && !matches!( + run.phase, + Phase::ReleasePd | Phase::ReleaseMod | Phase::RestoreCamera + ) + { + let owner_changed = self.modulation.as_ref().is_none_or(|m| { + m.owner_instance.as_str() != run.resolved.modulation_owner_instance + || !matches!(m.connection, ConnectionStateV1::Connected { .. }) + }) || self.photodiode.as_ref().is_none_or(|p| { + p.owner_instance.as_str() != run.photodiode_setup.owner_instance + || !matches!(p.connection, ConnectionStateV1::Connected { .. }) + }); + if owner_changed { + self.fail( + control, + "device owner disconnected or restarted during A2".into(), + ); + return; + } + } + if run.phase == Phase::Recording && !run.stop { + let current = self + .photodiode + .as_ref() + .and_then(|pd| pd.stream.sample_range) + .map(|r| r.end_sample_index_exclusive); + let now = now_ms(); + let run = self.run.as_mut().unwrap(); + let previous = run.pd_progress; + if let Some(index) = current { + if previous.is_none_or(|(old, _)| old != index) { + run.pd_progress = Some((index, now)); + } + } + if previous.is_some_and(|(_, at)| now.saturating_sub(at) > 5_000) + && previous == run.pd_progress + { + self.fail( + control, + "photodiode samples stopped advancing for 5 s during recording".into(), + ); + return; + } + } + let run = self.run.as_ref().unwrap(); + if let Some((_, _, sent)) = run.pending { + if now_ms().saturating_sub(sent) > TIMEOUT_MS { + match run.phase { + Phase::ReleasePd => { + self.run + .as_mut() + .unwrap() + .cleanup_failures + .push("photodiode lease release timed out".into()); + 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() + .cleanup_failures + .push("modulation safe-off/release timed out".into()); + 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 + .get_or_insert("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.valid = false; + run.stop = true; + let reason = "camera stop timed out after 3 attempts".to_string(); + run.evidence.failure.get_or_insert(reason.clone()); + run.abort_reason.get_or_insert(reason); + self.release_next(control); + } + } + Phase::StopMod => { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.cleanup_failures + .push("modulation stop timed out; release will retry safe-off".into()); + self.fail(control, "modulation stop timed out".into()); + } + _ => self.fail( + control, + format!("owner/host reply timed out after {TIMEOUT_MS} ms"), + ), + } + } + 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() + .or_else(|| run.evidence.failure.clone()) + .unwrap_or_else(|| "A2 stopped".into()); + self.fail(control, reason); + return; + } + if run.mod_leased + && run.pd_leased + && !run.stop + && !matches!( + run.phase, + Phase::StopMod + | Phase::FinalizePd + | Phase::StopCamera + | Phase::ReleasePd + | Phase::ReleaseMod + | Phase::RestoreCamera + ) + && 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 => { + if let Err(error) = self.write_sidecar() { + self.fail( + control, + format!("cannot save A2 metadata before recording: {error}"), + ); + return; + } + 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, + root_dir: Some( + self.run + .as_ref() + .unwrap() + .output_root + .to_string_lossy() + .into_owned(), + ), + 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::SetWaveform { + waveform: WaveformV1::Off, + }, + true, + ); + } + } + Phase::PlateauLowLevel | Phase::PlateauHighLevel => self.poll_plateau(control), + _ => {} + } + } + + fn accepted(&mut self, control: &mut impl Control, kind: PendingKind, payload: &Value) { + let phase = self.run.as_ref().unwrap().phase; + if kind != PendingKind::Host { + let common = match kind { + PendingKind::Mod | PendingKind::RenewMod => { + serde_json::from_value::(payload.clone()) + .map(|r| r.common) + } + _ => serde_json::from_value::(payload.clone()) + .map(|r| r.common), + }; + match common { + Ok(common) => { + let run = self.run.as_ref().unwrap(); + let owner = match kind { + PendingKind::Mod | PendingKind::RenewMod => { + &run.resolved.modulation_owner_instance + } + _ => &run.photodiode_setup.owner_instance, + }; + if run + .pending + .is_none_or(|(_, id, _)| id != common.request_id.0) + || common.owner_instance.as_str() != owner + || common + .run_id + .as_ref() + .is_none_or(|id| id.as_str() != run.lease_run_id) + { + self.rejected( + control, + kind, + "stale or mismatched owner response identity".into(), + ); + return; + } + if common.outcome == RequestOutcomeV1::Rejected { + let detail = common.error.map_or_else( + || "owner rejected without details".into(), + |e| owner_rejection_message(kind, &format!("{:?}", e.code), &e.message), + ); + self.rejected(control, kind, detail); + return; + } + if common.outcome == RequestOutcomeV1::InProgress { + return; + } + } + Err(_) => { + if !cfg!(test) || !payload.is_null() { + self.rejected(control, kind, "owner returned a malformed response".into()); + return; + } + } + } + } + if kind == PendingKind::Pd && phase == Phase::StartPd { + let receipt = serde_json::from_value::(payload.clone()) + .ok() + .and_then(|r| match r.receipt { + Some(PdqReceiptV1::Started(r)) => Some(r), + _ => None, + }); + if let Some(receipt) = receipt { + let pdq_path = self.resolve_owner_path(&receipt.pdq_path); + let sidecar_path = self.resolve_owner_path(&receipt.sidecar_path); + let error = self + .check_recording_directory(&pdq_path) + .err() + .or_else(|| self.check_recording_directory(&sidecar_path).err()); + let run = self.run.as_mut().unwrap(); + run.evidence.pdq_path = Some(pdq_path); + run.evidence.pd_sidecar_path = Some(sidecar_path); + if let Some(error) = error { + self.fail(control, format!("photodiode output path mismatch: {error}")); + return; + } + if let Err(error) = self.write_sidecar() { + self.fail( + control, + format!("cannot save opened photodiode paths: {error}"), + ); + return; + } + } else if !cfg!(test) || !payload.is_null() { + self.fail( + control, + "photodiode owner returned no opened-file receipt".into(), + ); + return; + } + } + if kind == PendingKind::RenewMod { + self.run.as_mut().unwrap().pending = None; + self.send_pd( + control, + PhotodiodeCommandV1::RenewLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + return; + } + if kind == PendingKind::RenewPd { + self.run.as_mut().unwrap().pending = None; + self.run.as_mut().unwrap().next_renew_ms = now_ms() + 20_000; + return; + } + self.run.as_mut().unwrap().pending = None; + if kind == PendingKind::Mod { + if let Ok(response) = serde_json::from_value::(payload.clone()) { + let evidence = &mut self.run.as_mut().unwrap().evidence; + if phase == Phase::Prepare || phase == Phase::StartStimulus { + evidence.marker_diagnostics_before = response.marker_diagnostics; + } + if phase == Phase::StopMod { + evidence.marker_diagnostics_after = response.marker_diagnostics; + } + } + } + if self.stop_pending || self.run.as_ref().unwrap().stop { + self.stop_pending = false; + let run = self.run.as_mut().unwrap(); + if kind == PendingKind::Mod && phase == Phase::AcquireMod { + run.mod_leased = true; + } + if kind == PendingKind::Pd && phase == Phase::AcquirePd { + run.pd_leased = true; + } + if !matches!( + phase, + Phase::StopMod + | Phase::FinalizePd + | Phase::StopCamera + | Phase::ReleasePd + | Phase::ReleaseMod + | Phase::RestoreCamera + ) { + self.fail(control, "operator stopped A2".into()); + return; + } + } + 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::PlateauLowDrive) => { + self.plateau_drive_acknowledged(control, true) + } + (PendingKind::Mod, Phase::PlateauHighDrive) => { + self.plateau_drive_acknowledged(control, false) + } + (PendingKind::Mod, Phase::Prepare) + if self.run.as_ref().unwrap().protocol.timing_reference + == A2TimingReferenceV1::DriveSync + && starts_modulation(&self.point().unwrap().acquisition) => + { + self.run.as_mut().unwrap().phase = Phase::QuietBeforeCapture; + self.send_mod( + control, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Off, + }, + true, + ); + } + (PendingKind::Mod, Phase::Prepare | Phase::QuietBeforeCapture) => { + let settle = (self.point().unwrap().settle_s * 1000.0) as u64; + let r = self.run.as_mut().unwrap(); + if phase == Phase::QuietBeforeCapture { + r.modulation_active = false; + } + r.phase = Phase::Settle; + r.deadline_ms = now_ms() + settle.max(250); + } + (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.controller.sample_rate_hz), + expected_stream_epoch: self.photodiode.as_ref().map(|p| p.stream.stream_epoch), + metadata: self.metadata(), + root_dir: Some(r.output_root.to_string_lossy().into_owned()), + }; + 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 self.run.as_ref().unwrap().protocol.timing_reference + == A2TimingReferenceV1::DriveSync + && starts_modulation(&self.point().unwrap().acquisition) => + { + // Both files are open before the first phase-zero pulse. The quiet + // lead-in makes the first shared cycle identifiable independently + // of USB/host recorder start latency. + self.send_prepare_a2(control, 0); + self.run.as_mut().unwrap().phase = Phase::StartStimulus; + } + (PendingKind::Pd, Phase::StartPd) | (PendingKind::Mod, Phase::StartStimulus) => { + let seconds = self.point().unwrap().acquisition_seconds(); + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Recording; + r.pd_progress = Some(( + self.photodiode + .as_ref() + .and_then(|pd| pd.stream.sample_range) + .map_or(0, |range| range.end_sample_index_exclusive), + now_ms(), + )); + r.deadline_ms = now_ms() + (seconds * 1000.0).ceil() as u64; + self.message = format!( + "Recording point {}: {} ({seconds:.3} s)", + r.index + 1, + r.protocol.points[r.index].label + ); + if let Err(error) = self.append_progress("point_started") { + self.fail(control, format!("cannot save point progress: {error}")); + } + } + (PendingKind::Mod, Phase::StopMod) => { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + if !run.pd_recording { + if run.camera_recording { + self.stop_camera(control); + } else { + self.release_next(control); + } + return; + } + 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 requested_seconds = self.point().unwrap().acquisition_seconds(); + let paths = finalized.as_ref().map(|receipt| { + ( + self.resolve_owner_path(&receipt.pdq_path), + self.resolve_owner_path(&receipt.sidecar_path), + ) + }); + let e = &mut self.run.as_mut().unwrap().evidence; + if let Some(receipt) = finalized { + let seconds = receipt + .sample_range + .zip(receipt.sample_rate_hz) + .filter(|(_, rate)| *rate > 0) + .map(|(range, rate)| range.sample_count as f64 / f64::from(rate)); + if seconds.is_none_or(|s| s + 0.05 < requested_seconds) { + e.failure.get_or_insert(format!("PDQ does not cover the requested {:.3} s: sampled duration={seconds:?} s", requested_seconds)); + } + e.pdq_marker_counts = receipt.marker_counts; + if let Some((pdq_path, sidecar_path)) = paths { + e.pdq_path = Some(pdq_path); + e.pd_sidecar_path = Some(sidecar_path); + } + e.pdq_sha256 = Some(receipt.sha256.to_string()); + if !receipt.valid + || !receipt.integrity.is_clean() + || receipt.sample_frames_written == 0 + || receipt.segment_count != 1 + || receipt.termination != PdqTerminationV1::Completed + { + e.failure.get_or_insert(format!("PDQ receipt invalid: termination={:?}, sample frames={}, segments={}, integrity={:?}", + receipt.termination, receipt.sample_frames_written, receipt.segment_count, receipt.integrity)); + } + } 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); + } + _ => {} + } + } + + 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 rejected(&mut self, control: &mut impl Control, kind: PendingKind, reason: String) { + let Some(run) = self.run.as_mut() else { return }; + run.pending = None; + match run.phase { + Phase::ReleasePd | Phase::ReleaseMod => { + run.cleanup_failures.push(reason); + if run.phase == Phase::ReleasePd { + run.pd_leased = false; + } else { + run.mod_leased = false; + } + self.release_next(control); + } + Phase::FinalizePd => { + run.pd_recording = false; + run.evidence.failure.get_or_insert(reason.clone()); + run.abort_reason.get_or_insert(reason); + run.stop = true; + self.stop_camera(control); + } + Phase::StopMod => { + run.modulation_active = false; + run.cleanup_failures.push(format!( + "modulation stop rejected: {reason}; release will retry safe-off" + )); + self.fail(control, reason); + } + _ => { + let _ = kind; + self.fail(control, reason); + } + } + } + + fn finish_async_mod(&mut self, control: &mut impl Control) { + let Some((kind @ (PendingKind::Mod | PendingKind::RenewMod), request_id, sent)) = + self.run.as_ref().and_then(|r| r.pending) + else { + return; + }; + if let Some(response) = self + .modulation + .as_ref() + .and_then(|s| s.last_response.as_ref()) + { + let run = self.run.as_ref().unwrap(); + if response.common.request_id.0 == request_id + && response.common.owner_instance.as_str() == run.resolved.modulation_owner_instance + && response + .common + .run_id + .as_ref() + .is_some_and(|id| id.as_str() == run.lease_run_id) + && response.common.outcome != RequestOutcomeV1::InProgress + { + let payload = serde_json::to_value(response).unwrap_or(Value::Null); + self.accepted(control, kind, &payload); + return; + } + } + let now = now_ms(); + let run = self.run.as_mut().unwrap(); + if now.saturating_sub(sent) <= TIMEOUT_MS && now.saturating_sub(run.last_mod_poll_ms) >= 200 + { + if let Some(request) = run + .pending_mod_request + .as_ref() + .filter(|r| r.request_id == request_id) + { + control.service(request); + run.last_mod_poll_ms = now; + } + } + } + + 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 reference = self.run.as_ref().unwrap().protocol.timing_reference; + let e = &mut self.run.as_mut().unwrap().evidence; + e.raw_path = Some(actual_raw_path.clone()); + e.raw_sha256 = Some(sha256.clone()); + if *size == 0 || *duration_us == 0 { + e.failure + .get_or_insert("camera receipt contains no recorded data".into()); + } + let trigger_problem = + validate_trigger_counts(&acquisition, e.rising_triggers, e.falling_triggers).err(); + if let Some(problem) = trigger_problem { + e.warnings.push(problem); + } + if starts_modulation(&acquisition) { + match (e.marker_diagnostics_before, e.marker_diagnostics_after) { + (Some(before), Some(after)) if before.dma_sample_clock && after.dma_sample_clock + && before.marker_drops == after.marker_drops + && before.stream_marker_drops == after.stream_marker_drops => {}, + counters => e.warnings.push(format!("Firmware marker-loss evidence needs review: {counters:?}. \ + Missing DMA-clock confirmation/status counters, resets or increasing drops prevent an assumed one-to-one clock match")), + } + match e.pdq_marker_counts { + Some(c) if c.invalid_level == 0 && match reference { + A2TimingReferenceV1::Comparator => c.comparator_rising > 0 && c.comparator_falling > 0, + A2TimingReferenceV1::DriveSync => c.phase_zero > 0, + } => {}, + counts => e.warnings.push(format!("PDQ shared time reference is missing or incomplete: {counts:?}. \ + Do not align by edge ordinal alone; inspect marker source, level and device ticks against camera RAW triggers")), + } + } + e.valid = e.failure.is_none() && e.warnings.is_empty(); + self.run.as_mut().unwrap().camera_recording = false; + } else if let HostCommandOutcome::RecordingPartial { + actual_raw_path, + sha256, + reason, + .. + } = outcome + { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.raw_path = Some(actual_raw_path.clone()); + run.evidence.raw_sha256 = sha256.clone(); + run.evidence + .failure + .get_or_insert(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 r = self.run.as_mut().unwrap(); + if r.evidence.failure.is_none() && !r.evidence.warnings.is_empty() { + if r.protocol.trigger_validation == TriggerValidation::Strict { + r.evidence.failure = Some(r.evidence.warnings.join("; ")); + } else { + r.review_points += 1; + } + } + if let Some(reason) = r.evidence.failure.clone() { + r.evidence.valid = false; + r.stop = true; + r.abort_reason.get_or_insert(format!( + "point {} '{}': {reason}", + r.index + 1, + r.protocol.points[r.index].label + )); + } + } + { + let run = self.run.as_mut().unwrap(); + run.evidence.acquisition_complete = run.evidence.failure.is_none() && !run.stop; + } + if let Err(error) = self + .write_sidecar() + .and_then(|()| self.append_progress("point_finished")) + { + let run = self.run.as_mut().unwrap(); + run.stop = true; + run.evidence.valid = false; + run.abort_reason + .get_or_insert(format!("cannot save A2 sidecar: {error}")); + } + if self.run.as_ref().is_some_and(|run| run.stop) { + 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 refusal = camera_configuration_refusal(&snapshot, 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 configuration 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.run.as_mut().unwrap().cleanup_failures.push(format!( + "camera restore was not confirmed after 3 attempts: {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.clone()); + if let Err(error) = self.check_recording_directory(&actual_raw_path) { + self.fail(control, error); + return; + } + if let Err(error) = self.write_sidecar() { + self.fail(control, format!("cannot save opened camera path: {error}")); + return; + } + self.accepted(control, PendingKind::Host, &Value::Null) + } + outcome => { + self.run.as_mut().unwrap().camera_recording = false; + self.fail(control, format!("camera start failed: {outcome:?}")); + } + } + } else if phase == Phase::StopCamera { + self.finish_point(control, &outcome) + } + } + + /// Anchor an owner-reported recording path to the run's output root. + /// + /// The photodiode owner echoes a workflow path exactly as the request + /// named it: relative to the `root_dir` it was given. Resolving it here + /// keeps every later check and every recorded artefact path absolute. + /// The components are pushed one by one because the request separates + /// them with `/`, which a Windows verbatim root (`\\?\C:\...`, what + /// `canonicalize` returns) does not read as a separator. + fn resolve_owner_path(&self, reported: &str) -> String { + let path = Path::new(reported); + if path.is_absolute() { + return reported.to_owned(); + } + let mut resolved = self.run.as_ref().unwrap().output_root.clone(); + resolved.extend(path.components()); + resolved.to_string_lossy().into_owned() + } + + fn check_recording_directory(&self, raw: &str) -> Result<(), String> { + let run = self.run.as_ref().unwrap(); + let expected = run.output_root.join(&run.measurement_id); + let parent = Path::new(raw) + .parent() + .ok_or("recorder returned no parent directory")?; + let actual = parent + .canonicalize() + .map_err(|e| format!("cannot verify recording output directory: {e}"))?; + if actual != expected.canonicalize().map_err(|e| e.to_string())? { + return Err(format!("recorder saved outside the measurement folder: {raw}; expected {}. Install the matching host with workflow recording-root support", expected.display())); + } + Ok(()) + } + + fn append_progress(&self, event: &str) -> Result<(), String> { + use std::io::Write; + let Some(run) = self.run.as_ref() else { + return Ok(()); + }; + let path = run + .output_root + .join(&run.measurement_id) + .join(format!("{}_progress.jsonl", run.attempt_id)); + let entry = json!({"schema":"stage-a.a2.progress.v1", "event":event, + "at_unix_ms":now_ms(), "measurement_id":run.measurement_id, "run_id":run.run_id, + "protocol_sha256":run.protocol_sha256, "point_index":run.index+1, + "point_total":run.protocol.points.len(), "completed_points":run.completed_points, + "resumed_rows":run.resumed_points.iter().map(|i| i + 1).collect::>(), + "phase":format!("{:?}",run.phase), "evidence":run.evidence, + "abort_reason":run.abort_reason, "cleanup_failures":run.cleanup_failures}); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| e.to_string())?; + writeln!(file, "{entry}").map_err(|e| e.to_string())?; + file.sync_data().map_err(|e| e.to_string()) + } + + fn write_sidecar(&self) -> Result<(), String> { + let r = self.run.as_ref().unwrap(); + let dir = r.output_root.join(&r.measurement_id); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + #[derive(Serialize)] + struct Side<'a> { + schema_version: u32, + measurement_id: &'a str, + attempt_id: &'a str, + output_root: &'a Path, + trigger_validation: TriggerValidation, + cleanup_failures: &'a [String], + experiment: &'static str, + scientific_status: &'static str, + timing_reference: A2TimingReferenceV1, + protocol_path: &'a str, + protocol_sha256: &'a str, + protocol_archive_path: &'a str, + protocol_name: &'a str, + protocol_row: usize, + camera_source: &'static str, + camera_provenance: Option<&'a CameraConfigurationProvenanceV1>, + camera_readback_age_s: Option, + photodiode_setup: &'a ResolvedPhotodiode, + controller: &'a ControllerSetup, + /// Kind-1 values the protocol no longer states, with the owner + /// provenance that replaces having typed them. + resolved_controller: &'a ResolvedController, + 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: 2, + measurement_id: &r.measurement_id, + attempt_id: &r.attempt_id, + output_root: &r.output_root, + trigger_validation: r.protocol.trigger_validation, + cleanup_failures: &r.cleanup_failures, + experiment: "A2", + scientific_status: "requires_offline_h4_h5_review", + timing_reference: r.protocol.timing_reference, + 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_source: "current_host_configuration", + camera_provenance: r.camera_provenance.as_ref(), + camera_readback_age_s: r.camera_readback_age_s, + photodiode_setup: &r.photodiode_setup, + controller: &r.controller, + resolved_controller: &r.resolved, + 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())?; + use std::io::Write; + let mut file = tempfile::NamedTempFile::new_in(&dir).map_err(|e| e.to_string())?; + file.write_all(&bytes).map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + file.persist(dir.join(format!("{}.a2.json", r.run_id))) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +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) { + // Host recording boundaries reset preview state, not the acquisition. + if let Some(run) = self.run.as_mut() { + run.last_event_bin_us = None; + run.last_event_bin_count = 0; + } + } + 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 r.camera_snapshot + .as_ref() + .is_some_and(|s| i32::from(s.external_trigger.channel) != i32::from(t.id)) + { + continue; + } + if t.is_rising() { + r.evidence.rising_triggers += 1 + } else { + r.evidence.falling_triggers += 1 + } + } + if r.evidence.peak_events_per_us > RECORDER_SAFETY_LIMIT_EVENTS_PER_US + && !r + .evidence + .warnings + .iter() + .any(|v| v.starts_with("Event-load review")) + { + r.evidence.warnings.push(format!("Event-load review: preview peak {} events/us exceeds the conservative {} events/us advisory; H21 must be evaluated offline", + r.evidence.peak_events_per_us, RECORDER_SAFETY_LIMIT_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 } => { + self.rejected( + c, + expected.unwrap().0, + owner_rejection_message(expected.unwrap().0, &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: "measurement_id".into(), label: "Measurement id".into(), tooltip: Some("Names the folder and every file. Leave blank to generate an id; repeated runs keep distinct filenames.".into()), kind: SettingKind::Text { default: self.measurement_id.clone() } }, + SettingItem { key: "new_id".into(), label: "New id".into(), tooltip: None, kind: SettingKind::Button { enabled: self.run.is_none() } }, + 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: self.run.is_none() } }, + // The UI mirror has no Run. The live worker validates these actions. + SettingItem { key: "continue_run".into(), label: "Continue".into(), tooltip: Some("Continue the current manual pause. Ignored when no pause is waiting.".into()), kind: SettingKind::Button { enabled: true } }, + SettingItem { key: "stop_protocol".into(), label: "Stop".into(), tooltip: None, kind: SettingKind::Button { enabled: true } }, + ], + }, + SettingsSection { + label: "Before the first A2 run".into(), + description: Some( + "Choose the data folder and references in the photodiode plugin. Apply the optical calibration in modulation. A2 sets each protocol point automatically and saves all files in the measurement folder.\n\nDrive-sync protocols use J24 common markers and determine optical ON/OFF timing offline. Comparator protocols retain their separate threshold and polarity checks." + .into(), + ), + default_open: false, + items: Vec::new(), + }, + ], + } + } + 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)), + "new_id" => Some(json!(self.new_id.value)), + "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> { + if self.run.is_some() + && matches!( + k, + "output_folder" | "measurement_id" | "protocol_path" | "new_id" + ) + { + return Err( + "Stop the current protocol before changing its measurement settings".into(), + ); + } + 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(); + self.protocol_preview = std::fs::read_to_string(self.protocol_path.trim()) + .ok() + .and_then(|s| protocol::parse(&s).ok()); + } + "new_id" => { + if self.new_id.accept(&v) { + self.measurement_id = format!("A2-{}", compact_time()); + } + } + "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 + ))); + v.push(StatusEntry::Text(format!( + "{} reused, {} newly recorded; about {} remaining, plus file finalization and manual pauses", + r.resumed_points.len(), r.completed_points, + format_bench_time(remaining_seconds(r, now_ms())) + ))); + if r.phase == Phase::Paused { + v.push(StatusEntry::Text( + "Waiting for Continue; manual pause time is not included".into(), + )); + } + v.push(StatusEntry::Text(format!( + "Files: {}", + r.output_root.join(&r.measurement_id).display() + ))); + } else { + if let Some(plan) = &self.protocol_preview { + let seconds: f64 = plan.points.iter().map(point_seconds).sum(); + let pauses = plan.points.iter().filter(|p| p.pause_before).count(); + v.push(StatusEntry::Text(format!( + "{} points; about {} plus file finalization and {pauses} manual pauses", + plan.points.len(), + format_bench_time(seconds) + ))); + } + if let Some(b) = self.blocker() { + v.push(StatusEntry::Text(format!("Not ready: {b}"))); + } + } + v + } +} + +fn valid_measurement_id(id: &str) -> bool { + let upper = id.to_ascii_uppercase(); + !id.is_empty() + && id.len() <= 100 + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + && !matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + && !(upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9')) +} + +fn resolve_output_root(folder: &str) -> Result { + let root = Path::new(folder.trim()); + if !root.is_absolute() { + return Err("choose an absolute data folder in the photodiode plugin".into()); + } + std::fs::create_dir_all(root).map_err(|e| format!("cannot create data folder: {e}"))?; + root.canonicalize() + .map_err(|e| format!("cannot resolve data folder: {e}")) +} + +fn point_seconds(point: &Point) -> f64 { + point.acquisition_seconds() + point.settle_s.max(0.25) +} + +fn remaining_seconds(run: &Run, now: u64) -> f64 { + let later: f64 = run + .protocol + .points + .iter() + .enumerate() + .skip(run.index + 1) + .filter(|(i, _)| !run.resumed_points.contains(i)) + .map(|(_, p)| point_seconds(p)) + .sum(); + let point = &run.protocol.points[run.index]; + let current = match run.phase { + Phase::Settle => { + run.deadline_ms.saturating_sub(now) as f64 / 1000.0 + point.acquisition_seconds() + } + Phase::StartCamera | Phase::StartPd | Phase::StartStimulus => point.acquisition_seconds(), + Phase::Recording => run.deadline_ms.saturating_sub(now) as f64 / 1000.0, + Phase::StopMod | Phase::FinalizePd | Phase::StopCamera => 0.0, + Phase::ReleasePd | Phase::ReleaseMod | Phase::RestoreCamera => return 0.0, + _ => point_seconds(point), + }; + if run.stop { + 0.0 + } else { + later + current + } +} + +fn format_bench_time(seconds: f64) -> String { + let seconds = seconds.ceil() as u64; + if seconds >= 3600 { + format!("{} h {:02} min", seconds / 3600, seconds % 3600 / 60) + } else if seconds >= 60 { + format!("{} min {:02} s", seconds / 60, seconds % 60) + } else { + format!("{seconds} s") + } +} + +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 photodiode_placement_name(placement: PhotodiodePlacementV1) -> &'static str { + match placement { + PhotodiodePlacementV1::RejectedPort => "rejected_port", + PhotodiodePlacementV1::CameraPath => "camera_path", + PhotodiodePlacementV1::EmissionPath => "emission_path", + } +} +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); + if std::fs::symlink_metadata(&directory).is_ok_and(|m| m.file_type().is_symlink()) { + return Err("measurement folder must not be a symlink; choose a new measurement id".into()); + } + 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() +} + +/// Quantized pedestal key. The milli units are exactly what reaches the +/// firmware, so two points that round to the same key really do share a drive. +fn pedestal_key(mean_u: f64, depth_a: f64) -> PedestalKey { + ( + (mean_u * 1000.0).round() as u32, + (depth_a * 1000.0).round() as u32, + ) +} + +fn resolve_photodiode(summary: &PhotodiodeSummaryV1) -> ResolvedPhotodiode { + ResolvedPhotodiode { + owner_instance: summary.owner_instance.to_string(), + placement: summary.placement, + splitter_fraction: summary.splitter_fraction, + reference_set_id: summary.reference_set_id.clone(), + load_ohms: summary.load_ohms, + dark_reference: summary.dark_reference.clone(), + sample_rate_hz: summary.stream.sample_rate_hz, + stream_epoch: summary.stream.stream_epoch, + stream_integrity: summary.stream.integrity, + } +} + +/// Resolves the Kind-1 controller values from their owners. +/// +/// Every branch either produces a value with its provenance or refuses. There +/// is no default: an unresolvable lobe or an unresolvable step floor must stop +/// the run while the bench is still untouched (ADR 040). +fn resolve_controller( + controller: &ControllerSetup, + modulation: Option<&ModulationStateV1>, + sensor: Option, +) -> Result { + let Some(state) = modulation else { + return Err( + "the Stage-A modulation owner has published no state, so the A2 lobe cannot be resolved" + .into(), + ); + }; + let Some(lobe) = state.optical_lobe.as_ref() else { + return Err( + "the modulation plugin has no applied optical calibration. Open its transfer-curve \ + calibration and select 'Apply to V_null / V_peak'. A2 sets mean_u and depth_a from \ + the protocol; you do not set a measurement point in the modulation plugin" + .into(), + ); + }; + if lobe.v_peak_dac <= lobe.v_null_dac { + return Err(format!( + "resolved lobe is not usable: v_peak_dac={} must exceed v_null_dac={}", + lobe.v_peak_dac, lobe.v_null_dac + )); + } + if lobe.v_peak_dac > STIMULUS_MAX_CODE { + return Err(format!( + "resolved lobe maximum {} is outside the stimulus DAC range 0..={STIMULUS_MAX_CODE}", + lobe.v_peak_dac + )); + } + if controller.comparator_hysteresis > 3 { + return Err(format!( + "comparator_hysteresis={} is outside the comparator's 0..=3 range", + controller.comparator_hysteresis + )); + } + if controller.min_half_us == Some(0) { + let dead_time = sensor + .and_then(|s| s.pixel_dead_time_us) + .filter(|v| v.is_finite() && *v > 0.0); + return Ok(ResolvedController { + lobe_source: "modulation_owner.optical_lobe", + v_null_dac: lobe.v_null_dac, + v_peak_dac: lobe.v_peak_dac, + modulation_calibration_id: lobe.calibration_id.clone(), + modulation_owner_instance: state.owner_instance.to_string(), + min_half_us: 0, + min_half_us_source: "offline_timing_review", + pixel_dead_time_us: dead_time, + refractory_floor_us: dead_time.map_or(0, |v| (5.0 * f64::from(v)).ceil() as u32), + settling_guard_us: 0, + }); + } + let Some(pixel_dead_time_us) = sensor.and_then(|s| s.pixel_dead_time_us) else { + return Err( + "sensor pixel-dead-time readout is missing, so the A2 step floor cannot be resolved" + .into(), + ); + }; + if !pixel_dead_time_us.is_finite() || pixel_dead_time_us <= 0.0 { + return Err(format!( + "sensor pixel-dead-time readout is {pixel_dead_time_us}, which is not a usable floor" + )); + } + let refractory_floor_us = (5.0 * f64::from(pixel_dead_time_us)).ceil() as u32; + let (min_half_us, min_half_us_source) = match controller.min_half_us { + // A frozen floor is kept verbatim, and still has to clear the sensor's + // own refractory bound: freezing a number may tighten the floor, never + // loosen it. + Some(frozen) => { + if f64::from(frozen) < 5.0 * f64::from(pixel_dead_time_us) { + return Err(format!( + "min_half_us={frozen} is below 5 x sensor dead time \ + ({pixel_dead_time_us:.2} us)" + )); + } + (frozen, "runner_configuration") + } + None => ( + refractory_floor_us.max(SETTLING_GUARD_US), + "sensor_telemetry", + ), + }; + Ok(ResolvedController { + lobe_source: "modulation_owner.optical_lobe", + v_null_dac: lobe.v_null_dac, + v_peak_dac: lobe.v_peak_dac, + modulation_calibration_id: lobe.calibration_id.clone(), + modulation_owner_instance: state.owner_instance.to_string(), + min_half_us, + min_half_us_source, + pixel_dead_time_us: Some(pixel_dead_time_us), + refractory_floor_us, + settling_guard_us: SETTLING_GUARD_US, + }) +} + +/// Checks every stepped half period against the resolved floor. +/// +/// [`crate::protocol::Protocol::validate`] can only do this when the protocol +/// froze the floor itself; a protocol that leaves it to owner resolution is +/// checked here instead, still before the camera apply and the owner leases. +fn check_half_periods(plan: &Protocol, min_half_us: u32) -> Result<(), String> { + for (index, point) in plan.points.iter().enumerate() { + if !point.acquisition_seconds().is_finite() + || point.acquisition_seconds() > 86_400.0 + || point.settle_s > 86_400.0 + { + return Err(format!( + "point {} exceeds the 24-hour duration limit", + index + 1 + )); + } + if let Acquisition::Stepped { half_period_s, .. } = point.acquisition { + let frequency = (500.0 / half_period_s).round() as u64; + if !stage_a_plugin_contract::drive_frequency_supported(frequency) + || 500_000_000.0 / (frequency as f64) < f64::from(min_half_us) + { + return Err(format!("point {} half-period cannot be produced within the firmware frequency range and resolved min_half_us", index + 1)); + } + } + + let Acquisition::Stepped { half_period_s, .. } = point.acquisition else { + continue; + }; + if half_period_s * 1e6 < f64::from(min_half_us) { + return Err(format!( + "point {} half-period {:.0} us is below the resolved min_half_us={min_half_us}", + index + 1, + half_period_s * 1e6 + )); + } + } + Ok(()) +} + +/// DAC code producing normalised optical intensity `u` on the increasing lobe: +/// `V(u) = V_null + (2 Vpi / pi) * arcsin(sqrt(u))`. +/// +/// The same inversion the firmware's `dacForU` applies, so a plateau this +/// plugin holds is exactly one of the two levels the `LOG_SQUARE` drive will +/// alternate between at the same pedestal. +fn dac_for_u(u: f64, v_null: f64, v_pi: f64) -> f64 { + v_null + (2.0 * v_pi / std::f64::consts::PI) * u.clamp(0.0, 1.0).sqrt().asin() +} + +/// The two static levels an A2 log-square alternates between at one pedestal. +/// +/// Mirrors `configureLogSquare`: `ln u` swings by `+-a/2` around `ln(mean_u)`, +/// and each level is inverted through the resolved lobe. Refuses for the same +/// reasons the firmware refuses, so an unreachable pedestal is caught at +/// preflight instead of half way through the run. +fn resolve_plateaus( + mean_u_milli: u32, + depth_a_milli: u32, + v_null_dac: u16, + v_peak_dac: u16, +) -> Result { + if !(1..=1_000).contains(&mean_u_milli) { + return Err(format!( + "pedestal mean_u={mean_u_milli} m is outside the lobe coordinate range (0, 1]" + )); + } + if depth_a_milli == 0 { + return Err("pedestal depth a quantises to zero".into()); + } + if v_peak_dac <= v_null_dac { + return Err("resolved lobe has no span".into()); + } + let mean_u = f64::from(mean_u_milli) / 1000.0; + let depth_a = f64::from(depth_a_milli) / 1000.0; + let v_null = f64::from(v_null_dac); + let v_pi = f64::from(v_peak_dac) - v_null; + let u_high = mean_u * (0.5 * depth_a).exp(); + let u_low = mean_u * (-0.5 * depth_a).exp(); + if u_high > 1.0 + 1e-6 { + return Err(format!( + "pedestal mean_u={mean_u:.3} at depth a={depth_a:.3} peaks at u={u_high:.3}, past the \ + lobe maximum" + )); + } + let code_high = dac_for_u(u_high, v_null, v_pi).round(); + let code_low = dac_for_u(u_low, v_null, v_pi).round(); + if !code_high.is_finite() || !code_low.is_finite() { + return Err("pedestal plateau inversion is not finite".into()); + } + let range = 0.0..=f64::from(STIMULUS_MAX_CODE); + if !range.contains(&code_low) || !range.contains(&code_high) { + return Err(format!( + "pedestal plateaus at DAC {code_low:.0}/{code_high:.0} leave the stimulus range \ + 0..={STIMULUS_MAX_CODE}" + )); + } + let low_dac = code_low as u16; + let high_dac = code_high as u16; + if low_dac == high_dac { + return Err(format!( + "pedestal mean_u={mean_u:.3} at depth a={depth_a:.3} quantises to a single DAC code \ + {low_dac}; there is no step to place a threshold inside" + )); + } + Ok(PedestalPlateaus { + mean_u_milli, + depth_a_milli, + low_dac, + high_dac, + }) +} + +/// Plateau pairs for every distinct pedestal that asks for an auto threshold. +fn plan_pedestals( + plan: &Protocol, + resolved: &ResolvedController, +) -> Result, String> { + let mut pedestals = BTreeMap::new(); + for (index, point) in plan.points.iter().enumerate() { + let Acquisition::Stepped { + mean_u, + depth_a, + comparator_threshold_dac, + .. + } = point.acquisition + else { + continue; + }; + let _ = comparator_threshold_dac; + let key = pedestal_key(mean_u, depth_a); + if pedestals.contains_key(&key) { + continue; + } + let plateaus = resolve_plateaus(key.0, key.1, resolved.v_null_dac, resolved.v_peak_dac) + .map_err(|error| format!("point {}: {error}", index + 1))?; + pedestals.insert(key, plateaus); + } + Ok(pedestals) +} + +/// Comparator threshold DAC code for a level measured on the photodiode ADC. +/// +/// The two converters do not share a reference: the photodiode ADC spans +/// 0..3300 mV over 4095 codes, the threshold DAC 0..2500 mV over 4095 codes. An +/// ADC code is therefore *not* a threshold code, and the only honest currency +/// between them is the physical voltage. `PhotodiodeLevelV1::mean_volts` has +/// already had the ADC affine map applied by the owner, so this converts volts +/// into the DAC's own scale — and refuses what the DAC cannot reach, because +/// clamping would park the threshold on a rail and still report success. +fn threshold_code_for_volts(volts: f64) -> Result { + if !volts.is_finite() { + return Err("plateau midpoint is not a finite voltage".into()); + } + let millivolt = volts * 1000.0; + let code = (millivolt * f64::from(THRESHOLD_MAX_CODE) / THRESHOLD_FULL_SCALE_MILLIVOLT).round(); + if !(0.0..=THRESHOLD_FULL_SCALE_MILLIVOLT).contains(&millivolt) + || !(1.0..=f64::from(THRESHOLD_MAX_CODE)).contains(&code) + { + return Err(format!( + "plateau midpoint {millivolt:.1} mV is outside the 0..2500 mV comparator threshold \ + DAC range" + )); + } + Ok(code as u16) +} + +fn aggregate_levels(step: &PlateauStep) -> PhotodiodeLevelV1 { + let levels: Vec<_> = step.windows.iter().flatten().copied().collect(); + let count: u64 = levels.iter().map(|v| v.sample_count).sum(); + PhotodiodeLevelV1 { + mean_volts: levels + .iter() + .map(|v| v.mean_volts * v.sample_count as f64) + .sum::() + / count as f64, + peak_to_peak_volts: levels + .iter() + .map(|v| v.peak_to_peak_volts) + .fold(0.0, f64::max), + sample_count: count, + end_sample_index: levels.last().unwrap().end_sample_index, + clipped: levels.iter().any(|v| v.clipped), + } +} + +fn mean_statistics(windows: &[PhotodiodeLevelV1]) -> (f64, f64) { + if windows.len() < 2 { + return (0.0, 0.0); + } + let n = windows.len() as f64; + let mean = windows.iter().map(|v| v.mean_volts).sum::() / n; + let variance = windows + .iter() + .map(|v| (v.mean_volts - mean).powi(2)) + .sum::() + / (n - 1.0); + let min = windows + .iter() + .map(|v| v.mean_volts) + .fold(f64::INFINITY, f64::min); + let max = windows + .iter() + .map(|v| v.mean_volts) + .fold(f64::NEG_INFINITY, f64::max); + (max - min, (variance / n).sqrt()) +} + +/// Turns two settled plateaus into a `V_50`, or explains why it will not. +fn measured_threshold( + probe: &PlateauProbe, + low: PhotodiodeLevelV1, + high: PhotodiodeLevelV1, +) -> Result { + let span_volts = high.mean_volts - low.mean_volts; + if !span_volts.is_finite() || span_volts < MIN_PLATEAU_SPAN_VOLTS { + return Err(format!( + "plateau span {:.3} mV is under the {:.3} mV threshold-DAC resolution floor at mean_u={} m, a={} m; increase the optical step", + span_volts * 1000.0, + MIN_PLATEAU_SPAN_VOLTS * 1000.0, + probe.mean_u_milli, + probe.depth_a_milli + )); + } + let low_windows: Vec<_> = probe.low.windows.iter().flatten().copied().collect(); + let high_windows: Vec<_> = probe.high.windows.iter().flatten().copied().collect(); + let (low_spread, low_se) = mean_statistics(&low_windows); + let (high_spread, high_se) = mean_statistics(&high_windows); + let difference_se = low_se.hypot(high_se); + if low_spread > MAX_PLATEAU_MEAN_SPREAD_FRACTION * span_volts + || high_spread > MAX_PLATEAU_MEAN_SPREAD_FRACTION * span_volts + || span_volts <= 6.0 * difference_se + { + return Err(format!( + "V50 unresolved: dim/bright mean {:.2}/{:.2} mV; step {:.2} mV; \ + spread of independent window means {:.2}/{:.2} mV; mean-difference SE {:.2} mV. \ + Raw peak-to-peak {:.1}/{:.1} mV. Increase optical step or reduce drift/noise", + low.mean_volts * 1000.0, + high.mean_volts * 1000.0, + span_volts * 1000.0, + low_spread * 1000.0, + high_spread * 1000.0, + difference_se * 1000.0, + low.peak_to_peak_volts * 1000.0, + high.peak_to_peak_volts * 1000.0 + )); + } + let midpoint_volts = 0.5 * (low.mean_volts + high.mean_volts); + let threshold_dac = threshold_code_for_volts(midpoint_volts)?; + Ok(MeasuredThreshold { + mean_u_milli: probe.mean_u_milli, + depth_a_milli: probe.depth_a_milli, + low_level_dac: probe.low.level_dac, + high_level_dac: probe.high.level_dac, + low_plateau_volts: low.mean_volts, + high_plateau_volts: high.mean_volts, + low_window_peak_to_peak_volts: low.peak_to_peak_volts, + high_window_peak_to_peak_volts: high.peak_to_peak_volts, + low_windows, + high_windows, + low_mean_spread_volts: low_spread, + high_mean_spread_volts: high_spread, + mean_difference_standard_error_volts: difference_se, + noisy_crossing_requires_review: low.peak_to_peak_volts > 0.5 * span_volts + || high.peak_to_peak_volts > 0.5 * span_volts, + span_volts, + midpoint_volts, + threshold_dac, + threshold_millivolt: f64::from(threshold_dac) * THRESHOLD_FULL_SCALE_MILLIVOLT + / f64::from(THRESHOLD_MAX_CODE), + low_window_sample_count: low.sample_count, + low_window_end_sample_index: low.end_sample_index, + low_drive_acknowledged_sample_index: probe.low.acknowledged_sample_index, + high_window_sample_count: high.sample_count, + high_window_end_sample_index: high.end_sample_index, + high_drive_acknowledged_sample_index: probe.high.acknowledged_sample_index, + stream_epoch: probe.high.stream_epoch, + measured_at_unix_ms: now_ms(), + }) +} + +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 pause_message(point: &Point) -> String { + if point.role == "blocked_drive_sham" { + return format!( + "Paused before '{}': keep the optical path blocked. A2 will run the drive \ + and record the blocked-drive electrical/crosstalk reference. Then press Continue.", + point.label + ); + } + match point.acquisition { + Acquisition::Dark { duration_s } => format!( + "Paused before '{}': close or block the optical path so no light reaches the camera \ + or photodiode. Check that the photodiode trace shows only the dark baseline. Then \ + press Continue. A2 switches modulation off and records {duration_s:.3} s \ + automatically.", + point.label + ), + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + .. + } => format!( + "Paused before '{}': open the optical path and confirm the sample is ready. Do not \ + set a measurement point in the modulation plugin. A2 automatically commands \ + mean_u={mean_u:.3}, depth_a={depth_a:.3}, and half-period={half_period_s:.6} s from \ + this protocol. Then press Continue.", + point.label + ), + } +} + +fn owner_rejection_message(kind: PendingKind, code: &str, message: &str) -> String { + let owner = match kind { + PendingKind::Mod | PendingKind::RenewMod => "modulation plugin", + PendingKind::Pd | PendingKind::RenewPd => "photodiode plugin", + PendingKind::Host => "camera host", + }; + let lease_problem = code.to_ascii_lowercase().contains("lease") + || message.to_ascii_lowercase().contains("lease"); + if lease_problem { + return format!( + "The {owner} rejected the A2 run because its lease state does not match ({code}: \ + {message}). This is a software state problem, not a hardware measurement failure. \ + Stop A2, reload A2, the modulation plugin and the photodiode plugin, then start the \ + protocol again." + ); + } + format!( + "The {owner} rejected the current A2 step ({code}: {message}). Check that plugin's status \ + line for the required action, then start A2 again." + ) +} + +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!( + "live-preview trigger count differs (preview is best-effort; verify RAW offline): expected {expected} +/- 1 per polarity, observed rising={rising}, falling={falling}" + )); + } + Ok(()) +} + +fn camera_configuration_refusal( + snapshot: &CameraConfigurationSnapshotV1, + readback_age_s: f64, +) -> Option { + if snapshot.digital_filter.stc_enabled + || snapshot.digital_filter.trail_enabled + || snapshot.digital_filter.erc_enabled != Some(false) + { + return Some( + "applied camera configuration must explicitly confirm STC, Trail and ERC off".into(), + ); + } + if !snapshot.external_trigger.enabled { + return Some("applied camera configuration has EXT_TRIGGER disabled".into()); + } + if !snapshot.global.record_sensor_telemetry { + return Some("applied camera configuration 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::*; + use crate::protocol::ComparatorThreshold; + + #[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" +[[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, + controller_mode: Some("A2".into()), + 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_lobe: Some(stage_a_plugin_contract::OpticalLobeStateV1 { + calibration_id: "lobe-1".into(), + v_null_dac: 100, + v_peak_dac: 1_000, + }), + 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), + reference_set_id: Some("pdref-test".into()), + load_ohms: Some(470_000.0), + 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 started_pd_payload(request_id: u64, run_id: &str, pdq: &str, sidecar: &str) -> Value { + use stage_a_plugin_contract::{ + OwnerInstanceId, PdqStartedReceiptV1, ResponseCommonV1, 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.split("_r").next().unwrap())), + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_ms()), + error: None, + }, + receipt: Some(PdqReceiptV1::Started(PdqStartedReceiptV1 { + run_id: RunId::new(run_id), + pdq_path: pdq.into(), + sidecar_path: sidecar.into(), + opened_at_unix_ms: now_ms(), + stream_epoch: 1, + first_sample_index: Some(0), + })), + }) + .unwrap() + } + + 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.split("_r").next().unwrap())), + 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, + marker_counts: Some(stage_a_plugin_contract::PdqMarkerCountsV1 { + comparator_rising: 2, + comparator_falling: 2, + phase_zero: 2, + invalid_level: 0, + }), + sample_range: Some(stage_a_plugin_contract::SampleRangeV1 { + first_sample_index: 0, + end_sample_index_exclusive: 5_000, + sample_count: 5_000, + }), + 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 pause_messages_separate_physical_actions_from_automatic_settings() { + let dark = pause_message(&paused_point()); + assert!(dark.contains("close or block the optical path"), "{dark}"); + assert!(dark.contains("press Continue"), "{dark}"); + assert!(dark.contains("automatically"), "{dark}"); + + let stepped = Point { + label: "technical_pre".into(), + role: "technical".into(), + settle_s: 0.0, + pause_before: true, + acquisition: Acquisition::Stepped { + mean_u: 0.3, + depth_a: 0.45, + half_period_s: 0.001, + transitions_per_polarity: 2, + comparator_threshold_dac: ComparatorThreshold::Auto, + }, + }; + let message = pause_message(&stepped); + assert!(message.contains("open the optical path"), "{message}"); + assert!( + message.contains("Do not set a measurement point"), + "{message}" + ); + assert!(message.contains("mean_u=0.300"), "{message}"); + assert!(message.contains("depth_a=0.450"), "{message}"); + assert!(message.contains("press Continue"), "{message}"); + } + + #[test] + fn lease_rejection_names_the_problem_and_recovery_action() { + let message = owner_rejection_message( + PendingKind::Mod, + "lease_mismatch", + "request run does not match the leased run", + ); + assert!(message.contains("software state problem"), "{message}"); + assert!(message.contains("reload A2"), "{message}"); + assert!(message.contains("modulation plugin"), "{message}"); + } + + #[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: ComparatorThreshold::Frozen(500), + }; + assert!(validate_trigger_counts(&acquisition, 100, 99).is_ok()); + assert!(validate_trigger_counts(&acquisition, 2, 2).is_err()); + } + + #[test] + fn camera_configuration_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, + }, + }; + assert!(camera_configuration_refusal(&snapshot, 0.1) + .unwrap() + .contains("ERC")); + snapshot.digital_filter.erc_enabled = Some(false); + assert!(camera_configuration_refusal(&snapshot, 0.1).is_none()); + } + + #[test] + fn a2_ui_reads_owner_settings_but_offers_a_measurement_id() { + let plugin = StageAA2Plugin::default(); + let keys = plugin + .settings_schema() + .sections + .into_iter() + .flat_map(|section| section.items) + .map(|item| item.key) + .collect::>(); + assert!(!keys.iter().any(|key| key == "output_folder")); + assert!(keys.iter().any(|key| key == "measurement_id")); + assert!(keys.iter().any(|key| key == "protocol_path")); + } + + #[test] + fn manifest_declares_every_camera_command_used_by_a2() { + let manifest: toml::Value = toml::from_str(include_str!("../plugin.toml")).unwrap(); + let commands = manifest["host_commands"] + .as_array() + .unwrap() + .iter() + .filter_map(toml::Value::as_str) + .collect::>(); + for required in [ + "start_recording", + "stop_recording", + "apply_camera_configuration", + "restore_camera_configuration", + ] { + assert!(commands.contains(&required), "missing {required}"); + } + } + + #[test] + fn end_to_end_runs_paused_dark_then_stepped_and_restores_camera() { + let mut plugin = ready_plugin("e2e-success"); + let expected_output_folder = plugin + .photodiode + .as_ref() + .and_then(|summary| summary.data_dir.clone()) + .unwrap(); + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert_eq!( + Path::new(&plugin.output_folder), + Path::new(&expected_output_folder).canonicalize().unwrap() + ); + assert!(plugin.measurement_id.starts_with("A2-")); + let measurement_id = plugin.measurement_id.clone(); + assert!(matches!( + &control.hosts.last().unwrap().command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current + } + )); + 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); + + let acquire_mod: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let lease_run_id = acquire_mod.run_id.clone().expect("modulation lease run id"); + + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + let acquire_pd: PhotodiodeRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert_eq!(acquire_pd.run_id.as_ref(), Some(&lease_run_id)); + 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_eq!(dark_prepare.run_id.as_ref(), Some(&lease_run_id)); + assert!(matches!( + dark_prepare.command, + ModulationCommandV1::SafeOff { .. } + )); + 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: Path::new(&expected_output_folder) + .join(&measurement_id) + .join("dark.raw") + .to_string_lossy() + .into_owned(), + 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: Path::new(&expected_output_folder) + .join(&measurement_id) + .join("dark.raw") + .to_string_lossy() + .into_owned(), + size: 1, + sha256: "ef".repeat(32), + duration_us: 1_000, + }, + ); + + let sidecar_path = Path::new(&expected_output_folder) + .join(&measurement_id) + .join(format!("{run_id}.a2.json")); + let sidecar: Value = serde_json::from_slice(&std::fs::read(sidecar_path).unwrap()).unwrap(); + assert_eq!(sidecar["camera_source"], "current_host_configuration"); + assert_eq!( + sidecar["photodiode_setup"]["reference_set_id"], + "pdref-test" + ); + assert_eq!(sidecar["photodiode_setup"]["load_ohms"], 470_000.0); + assert_eq!( + sidecar["scientific_status"], + "requires_offline_h4_h5_review" + ); + + 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: Path::new(&expected_output_folder) + .join(&measurement_id) + .join("step.raw") + .to_string_lossy() + .into_owned(), + started_at: "now".into(), + }, + ); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Recording); + assert!( + !control.services.iter().any(|request| { + serde_json::from_value::(request.payload.clone()) + .is_ok_and(|r| matches!(r.command, ModulationCommandV1::StartAcquisition)) + }), + "A2 must never reset the PD sample clock with START" + ); + { + let run = plugin.run.as_mut().unwrap(); + run.evidence.rising_triggers = 2; + run.evidence.falling_triggers = 2; + let counters = stage_a_plugin_contract::A2MarkerDiagnosticsV1 { + dma_sample_clock: true, + marker_drops: 0, + stream_marker_drops: 0, + observed_at_unix_ms: now_ms(), + }; + run.evidence.marker_diagnostics_before = Some(counters); + run.evidence.marker_diagnostics_after = Some(counters); + 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: Path::new(&expected_output_folder) + .join(&measurement_id) + .join("step.raw") + .to_string_lossy() + .into_owned(), + 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("A2 stopped")); + } + + // --------------------------------------------------------------------- + // Stage 1 — Kind-1 values resolved from their owners. + // --------------------------------------------------------------------- + + fn test_sensor(pixel_dead_time_us: Option) -> SensorMonitoringV1 { + SensorMonitoringV1 { + pixel_dead_time_us, + illumination_lux: Some(0.1), + temperature_c: Some(25.0), + bias_codes: None, + age_s: 0.1, + } + } + + fn parsed(text: &str) -> Protocol { + protocol::parse(text).unwrap() + } + + fn calibrated_lobe(calibration_id: &str) -> stage_a_plugin_contract::OpticalLobeStateV1 { + stage_a_plugin_contract::OpticalLobeStateV1 { + calibration_id: calibration_id.into(), + v_null_dac: 100, + v_peak_dac: 1_000, + } + } + + fn modulation_state( + lobe: Option, + ) -> ModulationStateV1 { + let mut plugin = ready_plugin("resolve-helper"); + let mut state = plugin.modulation.take().unwrap(); + state.optical_lobe = lobe; + state.optical_drive = None; + state + } + + #[test] + fn an_applied_lobe_is_required_but_an_armed_measurement_point_is_not() { + let controller = ControllerSetup::default(); + let state = modulation_state(None); + let error = resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))) + .unwrap_err(); + assert!(error.contains("Apply to V_null / V_peak"), "{error}"); + + let state = modulation_state(Some(calibrated_lobe("lobe-1"))); + assert!(state.optical_drive.is_none()); + resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))).unwrap(); + + let error = + resolve_controller(&controller, None, Some(test_sensor(Some(10.0)))).unwrap_err(); + assert!(error.contains("modulation owner"), "{error}"); + } + + #[test] + fn a_degenerate_lobe_refuses_against_the_resolved_values() { + let controller = ControllerSetup::default(); + let mut lobe = calibrated_lobe("lobe-1"); + lobe.v_peak_dac = lobe.v_null_dac; + let state = modulation_state(Some(lobe)); + let error = resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))) + .unwrap_err(); + assert!(error.contains("v_peak_dac"), "{error}"); + } + + #[test] + fn the_resolved_lobe_and_its_calibration_id_reach_the_run_provenance() { + let controller = ControllerSetup::default(); + let state = modulation_state(Some(calibrated_lobe("pockels-2026-08-01"))); + let resolved = + resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))).unwrap(); + assert_eq!(resolved.v_null_dac, 100); + assert_eq!(resolved.v_peak_dac, 1_000); + assert_eq!(resolved.lobe_source, "modulation_owner.optical_lobe"); + assert_eq!(resolved.modulation_calibration_id, "pockels-2026-08-01"); + } + + #[test] + fn an_absent_min_half_us_resolves_to_the_larger_of_refractory_and_settling_floors() { + let controller = ControllerSetup::default(); + let state = modulation_state(Some(calibrated_lobe("lobe-1"))); + + // 5 x 10 us = 50 us, so the settling guard dominates. + let resolved = + resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))).unwrap(); + assert_eq!(resolved.refractory_floor_us, 50); + assert_eq!(resolved.min_half_us, SETTLING_GUARD_US); + assert_eq!(resolved.min_half_us_source, "sensor_telemetry"); + + // 5 x 400 us = 2000 us, so the sensor dominates. + let resolved = + resolve_controller(&controller, Some(&state), Some(test_sensor(Some(400.0)))).unwrap(); + assert_eq!(resolved.refractory_floor_us, 2_000); + assert_eq!(resolved.min_half_us, 2_000); + assert_eq!(resolved.min_half_us_source, "sensor_telemetry"); + } + + #[test] + fn a_frozen_min_half_us_is_kept_and_still_checked_against_the_sensor() { + let controller = ControllerSetup { + min_half_us: Some(1_000), + ..ControllerSetup::default() + }; + let state = modulation_state(Some(calibrated_lobe("lobe-1"))); + let resolved = + resolve_controller(&controller, Some(&state), Some(test_sensor(Some(10.0)))).unwrap(); + assert_eq!(resolved.min_half_us, 1_000); + assert_eq!(resolved.min_half_us_source, "runner_configuration"); + + // The pre-existing runtime gate: a frozen floor under 5 x dead time is + // still refused, and refused before anything moves. + let error = resolve_controller(&controller, Some(&state), Some(test_sensor(Some(300.0)))) + .unwrap_err(); + assert!(error.contains("5 x sensor dead time"), "{error}"); + } + + #[test] + fn a_missing_pixel_dead_time_refuses_rather_than_resolving_a_floor_from_nothing() { + let controller = ControllerSetup::default(); + let state = modulation_state(Some(calibrated_lobe("lobe-1"))); + let error = + resolve_controller(&controller, Some(&state), Some(test_sensor(None))).unwrap_err(); + assert!(error.contains("pixel-dead-time"), "{error}"); + let error = resolve_controller(&controller, Some(&state), None).unwrap_err(); + assert!(error.contains("pixel-dead-time"), "{error}"); + } + + #[test] + fn every_stepped_half_period_is_checked_against_the_resolved_floor() { + let plan = parsed(test_protocol()); + // The protocol's stepped point has a 1000 us half period. + assert!(check_half_periods(&plan, 1_000).is_ok()); + let error = check_half_periods(&plan, 1_001).unwrap_err(); + assert!(error.contains("resolved min_half_us"), "{error}"); + } + + // --------------------------------------------------------------------- + // Stage 2 — V_50 measured per pedestal. + // --------------------------------------------------------------------- + + /// The firmware's own inversion, written out independently of the plugin's + /// helper so a wiring mistake in the plugin cannot hide behind it. + fn firmware_dac_for_u(u: f64, v_null: f64, v_pi: f64) -> u16 { + (v_null + (2.0 * v_pi / std::f64::consts::PI) * u.sqrt().asin()).round() as u16 + } + + #[test] + fn plateau_codes_match_the_firmware_log_square_inversion() { + let plateaus = resolve_plateaus(300, 450, 100, 1_000).unwrap(); + let expected_low = firmware_dac_for_u(0.3 * (-0.225_f64).exp(), 100.0, 900.0); + let expected_high = firmware_dac_for_u(0.3 * (0.225_f64).exp(), 100.0, 900.0); + assert_eq!(plateaus.low_dac, expected_low); + assert_eq!(plateaus.high_dac, expected_high); + assert!(plateaus.low_dac < plateaus.high_dac); + assert_eq!(plateaus.mean_u_milli, 300); + assert_eq!(plateaus.depth_a_milli, 450); + } + + #[test] + fn a_pedestal_that_would_saturate_the_lobe_refuses_at_preflight() { + // 0.9 * e^(0.4) = 1.34, past the lobe maximum. + let error = resolve_plateaus(900, 800, 100, 1_000).unwrap_err(); + assert!(error.contains("past the lobe maximum"), "{error}"); + } + + #[test] + fn a_pedestal_whose_step_quantises_away_refuses_at_preflight() { + // A one-milli depth on a narrow lobe collapses both plateaus onto one + // code: there is no step for a threshold to sit inside. + let error = resolve_plateaus(300, 1, 100, 110).unwrap_err(); + assert!(error.contains("single DAC code"), "{error}"); + } + + #[test] + fn plan_pedestals_validates_frozen_points_too() { + let plan = parsed(auto_protocol()); + let state = modulation_state(Some(calibrated_lobe("lobe-1"))); + let resolved = resolve_controller( + &ControllerSetup::default(), + Some(&state), + Some(test_sensor(Some(10.0))), + ) + .unwrap(); + let pedestals = plan_pedestals(&plan, &resolved).unwrap(); + // Two auto points share (300, 450); the third is frozen. + assert_eq!(pedestals.len(), 1); + assert!(pedestals.contains_key(&(300, 450))); + + let frozen_only = parsed(test_protocol()); + assert_eq!(plan_pedestals(&frozen_only, &resolved).unwrap().len(), 1); + } + + #[test] + fn an_adc_level_is_never_copied_across_as_a_threshold_code() { + // ADC code 2048 on a 3300 mV reference is 1.6505 V; the threshold DAC + // spans 2500 mV, so the same voltage is a very different code. + let volts = 2_048.0 * 3.3 / 4_095.0; + let code = threshold_code_for_volts(volts).unwrap(); + assert_ne!(code, 2_048); + let expected = (volts * 1000.0 * 4_095.0 / 2_500.0).round() as u16; + assert_eq!(code, expected); + assert!( + code > 2_048, + "the DAC's smaller span must give a larger code" + ); + } + + #[test] + fn a_midpoint_the_threshold_dac_cannot_reach_refuses() { + assert!(threshold_code_for_volts(3.0).unwrap_err().contains("2500")); + assert!(threshold_code_for_volts(0.0).is_err()); + assert!(threshold_code_for_volts(f64::NAN).is_err()); + } + + fn level( + mean_volts: f64, + peak_to_peak_volts: f64, + end_sample_index: u64, + sample_count: u64, + clipped: bool, + ) -> PhotodiodeLevelV1 { + PhotodiodeLevelV1 { + mean_volts, + peak_to_peak_volts, + sample_count, + end_sample_index, + clipped, + } + } + + fn probe(low: PhotodiodeLevelV1, high: PhotodiodeLevelV1) -> PlateauProbe { + PlateauProbe { + mean_u_milli: 300, + depth_a_milli: 450, + low: PlateauStep { + level_dac: 393, + acknowledged_sample_index: 1_000, + stream_epoch: 1, + level: Some(low), + windows: [Some(low); PLATEAU_WINDOWS], + window_count: PLATEAU_WINDOWS, + }, + high: PlateauStep { + level_dac: 478, + acknowledged_sample_index: 2_000, + stream_epoch: 1, + level: Some(high), + windows: [Some(high); PLATEAU_WINDOWS], + window_count: PLATEAU_WINDOWS, + }, + } + } + + #[test] + fn the_threshold_is_the_plateau_midpoint_converted_through_millivolts() { + let low = level(1.0, 0.001, 1_500, 400, false); + let high = level(1.2, 0.001, 2_500, 400, false); + let measured = measured_threshold(&probe(low, high), low, high).unwrap(); + assert!((measured.span_volts - 0.2).abs() < 1e-9); + assert!((measured.midpoint_volts - 1.1).abs() < 1e-9); + // 1100 mV * 4095 / 2500 = 1801.8 + assert_eq!(measured.threshold_dac, 1_802); + assert_eq!(measured.low_window_end_sample_index, 1_500); + assert_eq!(measured.low_drive_acknowledged_sample_index, 1_000); + assert_eq!(measured.high_window_end_sample_index, 2_500); + assert_eq!(measured.high_drive_acknowledged_sample_index, 2_000); + } + + #[test] + fn a_span_too_small_to_hold_a_threshold_refuses_rather_than_centring_in_noise() { + let low = level(1.000, 0.0005, 1_500, 400, false); + let high = level(1.001, 0.0005, 2_500, 400, false); + let error = measured_threshold(&probe(low, high), low, high).unwrap_err(); + assert!(error.contains("threshold-DAC resolution floor"), "{error}"); + } + + #[test] + fn an_inverted_plateau_pair_refuses() { + let low = level(1.2, 0.001, 1_500, 400, false); + let high = level(1.0, 0.001, 2_500, 400, false); + assert!(measured_threshold(&probe(low, high), low, high).is_err()); + } + + #[test] + fn stable_means_with_large_raw_noise_remain_recordable_but_require_review() { + let low = level(0.380, 0.380, 1_500, 400, false); + let high = level(0.400, 0.380, 2_500, 400, false); + let result = measured_threshold(&probe(low, high), low, high).unwrap(); + assert!((result.midpoint_volts - 0.390).abs() < 1e-10); + assert!(result.noisy_crossing_requires_review); + assert_eq!(result.low_windows.len(), PLATEAU_WINDOWS); + } + + #[test] + fn drifting_window_means_refuse_even_when_raw_samples_do_not_clip() { + let low = level(0.38, 0.380, 1_500, 400, false); + let high = level(0.40, 0.380, 2_500, 400, false); + let mut p = probe(low, high); + p.low.windows[3].as_mut().unwrap().mean_volts += 0.015; + let error = measured_threshold(&p, low, high).unwrap_err(); + assert!(error.contains("V50 unresolved"), "{error}"); + } + + fn auto_protocol() -> &'static str { + r#" +name="a2-auto" +[[point]] +label="auto_a" +role="identification" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.001 +transitions_per_polarity=2 +settle_s=0 +[[point]] +label="auto_b" +role="identification" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.001 +transitions_per_polarity=2 +settle_s=0 +[[point]] +label="frozen_c" +role="identification" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.001 +transitions_per_polarity=2 +settle_s=0 +comparator_threshold_dac=500 +"# + } + + fn ready_auto_plugin(label: &str) -> StageAA2Plugin { + let plugin = ready_plugin(label); + std::fs::write(&plugin.protocol_path, auto_protocol()).unwrap(); + plugin + } + + fn publish_stream( + plugin: &mut StageAA2Plugin, + stream_epoch: u64, + end_sample_index: u64, + published: Option, + ) { + let pd = plugin.photodiode.as_mut().unwrap(); + pd.stream.stream_epoch = stream_epoch; + pd.stream.sample_range = Some(stage_a_plugin_contract::SampleRangeV1 { + first_sample_index: 0, + end_sample_index_exclusive: end_sample_index, + sample_count: end_sample_index, + }); + pd.stream.level = published; + } + + fn publish_plateau_windows( + plugin: &mut StageAA2Plugin, + control: &mut MockControl, + mean: f64, + raw_spread: f64, + ) { + let run = plugin.run.as_ref().unwrap(); + let probe = run.probe.unwrap(); + let step = if run.phase == Phase::PlateauLowLevel { + probe.low + } else { + probe.high + }; + let mut end = step.acknowledged_sample_index + 125_000; + for _ in 0..PLATEAU_WINDOWS { + end += 10_000; + publish_stream( + plugin, + step.stream_epoch, + end, + Some(level(mean, raw_spread, end, 10_000, false)), + ); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(control); + } + } + + /// Walks a fresh plugin as far as "the dim plateau is commanded and + /// acknowledged", which is where every Stage-2 refusal path branches. + fn plugin_at_low_plateau(label: &str) -> (StageAA2Plugin, MockControl) { + let mut plugin = ready_auto_plugin(label); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply, applied_camera_outcome()); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + publish_stream(&mut plugin, 1, 1_000, None); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!( + plugin.run.as_ref().unwrap().phase, + Phase::PlateauLowDrive, + "an auto point must measure before it prepares" + ); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::PlateauLowLevel); + (plugin, control) + } + + #[test] + fn an_auto_point_holds_both_plateaus_before_it_prepares() { + let plateaus = resolve_plateaus(300, 450, 100, 1_000).unwrap(); + let mut plugin = ready_auto_plugin("auto-v50"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply, applied_camera_outcome()); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + publish_stream(&mut plugin, 1, 1_000, None); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert_eq!( + request.command, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Constant { + level_dac: plateaus.low_dac + } + } + ); + + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + publish_plateau_windows(&mut plugin, &mut control, 1.0, 0.001); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::PlateauHighDrive); + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert_eq!( + request.command, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Constant { + level_dac: plateaus.high_dac + } + } + ); + + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + let high_ack = plugin + .run + .as_ref() + .unwrap() + .probe + .unwrap() + .high + .acknowledged_sample_index; + publish_plateau_windows(&mut plugin, &mut control, 1.2, 0.001); + + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Prepare); + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let ModulationCommandV1::PrepareA2 { configuration } = request.command else { + panic!("expected PrepareA2 after the plateau measurement"); + }; + assert_eq!(configuration.comparator_threshold_dac, 1_802); + assert_eq!(configuration.v_null_dac, 100); + assert_eq!(configuration.v_peak_dac, 1_000); + assert_eq!(configuration.min_half_us, 1_000); + + let evidence = &plugin.run.as_ref().unwrap().evidence; + assert_eq!(evidence.comparator_threshold_mode, Some("auto")); + assert_eq!(evidence.comparator_threshold_dac, Some(1_802)); + let measurement = evidence.threshold_measurement.as_ref().unwrap(); + assert_eq!(measurement.low_level_dac, plateaus.low_dac); + assert_eq!(measurement.high_level_dac, plateaus.high_dac); + assert_eq!(measurement.low_drive_acknowledged_sample_index, 1_000); + assert_eq!(measurement.high_drive_acknowledged_sample_index, high_ack); + + // Repeated coordinates are remeasured: flux or baseline may have drifted. + { + let run = plugin.run.as_mut().unwrap(); + run.pending = None; + run.index = 1; + } + plugin.prepare(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::PlateauLowDrive); + + // A frozen point keeps its own code and is recorded as a claim. + { + let run = plugin.run.as_mut().unwrap(); + run.pending = None; + run.index = 2; + } + plugin.prepare(&mut control); + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let ModulationCommandV1::PrepareA2 { configuration } = request.command else { + panic!("expected PrepareA2 for the frozen point"); + }; + assert_eq!(configuration.comparator_threshold_dac, 500); + assert_eq!( + plugin + .run + .as_ref() + .unwrap() + .evidence + .comparator_threshold_mode, + Some("frozen") + ); + } + + #[test] + fn a_clipped_plateau_refuses_the_point() { + let (mut plugin, mut control) = plugin_at_low_plateau("auto-clipped"); + publish_stream( + &mut plugin, + 1, + 136_000, + Some(level(3.29, 0.001, 136_000, 10_000, true)), + ); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + assert!(plugin.message.contains("clips"), "{}", plugin.message); + assert!(plugin.run.as_ref().unwrap().stop); + } + + #[test] + fn a_level_window_that_started_before_the_drive_is_never_accepted() { + let (mut plugin, mut control) = plugin_at_low_plateau("auto-stale-window"); + // Ends at 1200 over 400 samples, so it starts at 800 — before the drive + // was acknowledged at 1000. + publish_stream( + &mut plugin, + 1, + 1_200, + Some(level(1.0, 0.001, 1_200, 400, false)), + ); + { + let run = plugin.run.as_mut().unwrap(); + run.deadline_ms = 0; + run.plateau_timeout_ms = 0; + } + plugin.drive(&mut control); + assert!( + plugin.message.contains("began after the acknowledged"), + "{}", + plugin.message + ); + } + + #[test] + fn a_restarted_photodiode_stream_invalidates_the_plateau_measurement() { + let (mut plugin, mut control) = plugin_at_low_plateau("auto-epoch"); + publish_stream( + &mut plugin, + 2, + 9_000, + Some(level(1.0, 0.001, 9_000, 400, false)), + ); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + assert!( + plugin.message.contains("stream restarted"), + "{}", + plugin.message + ); + } + + #[test] + fn a_plateau_drive_without_a_published_sample_range_refuses() { + let mut plugin = ready_auto_plugin("auto-no-range"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply, applied_camera_outcome()); + 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::PlateauLowDrive); + // The owner has published no sample range, so nothing can anchor the + // level window to the drive. + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert!( + plugin.message.contains("no sample range"), + "{}", + plugin.message + ); + } + + #[test] + fn an_unresolvable_lobe_refuses_before_the_camera_is_touched() { + let mut plugin = ready_auto_plugin("auto-no-lobe"); + plugin.modulation.as_mut().unwrap().optical_lobe = None; + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(plugin.run.is_none()); + assert!(control.hosts.is_empty(), "no camera command may be issued"); + assert!(control.services.is_empty(), "no owner lease may be taken"); + assert!(plugin.message.contains("refused"), "{}", plugin.message); + } + #[test] + fn repeated_or_overlapping_pd_windows_do_not_create_false_precision() { + let (mut plugin, mut control) = plugin_at_low_plateau("overlap"); + publish_stream( + &mut plugin, + 1, + 136_000, + Some(level(0.38, 0.380, 136_000, 10_000, false)), + ); + plugin.run.as_mut().unwrap().deadline_ms = 0; + for _ in 0..20 { + plugin.drive(&mut control); + } + assert_eq!( + plugin.run.as_ref().unwrap().probe.unwrap().low.window_count, + 1 + ); + publish_stream( + &mut plugin, + 1, + 140_000, + Some(level(0.38, 0.380, 140_000, 10_000, false)), + ); + plugin.drive(&mut control); + assert_eq!( + plugin.run.as_ref().unwrap().probe.unwrap().low.window_count, + 1 + ); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::PlateauLowLevel); + } + + #[test] + fn a_window_sampled_before_settling_cannot_be_accepted_late() { + let (mut plugin, mut control) = plugin_at_low_plateau("settle-samples"); + publish_stream( + &mut plugin, + 1, + 21_000, + Some(level(0.38, 0.001, 21_000, 10_000, false)), + ); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + assert_eq!( + plugin.run.as_ref().unwrap().probe.unwrap().low.window_count, + 0 + ); + } + + #[test] + fn lease_renewal_does_not_advance_the_plateau_state_machine() { + let (mut plugin, mut control) = plugin_at_low_plateau("renewal-phase"); + plugin.run.as_mut().unwrap().next_renew_ms = 0; + plugin.drive(&mut control); + assert_eq!( + plugin.run.as_ref().unwrap().pending.unwrap().0, + PendingKind::RenewMod + ); + plugin.accepted(&mut control, PendingKind::RenewMod, &Value::Null); + assert_eq!( + plugin.run.as_ref().unwrap().pending.unwrap().0, + PendingKind::RenewPd + ); + plugin.accepted(&mut control, PendingKind::RenewPd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::PlateauLowLevel); + assert!(plugin.run.as_ref().unwrap().next_renew_ms > now_ms()); + } + + #[test] + fn aborting_a_constant_probe_turns_off_the_drive_without_finalizing_an_unopened_pdq() { + let (mut plugin, mut control) = plugin_at_low_plateau("stop-plateau"); + plugin.fail(&mut control, "test reason".into()); + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + request.command, + ModulationCommandV1::SetWaveform { + waveform: WaveformV1::Off + } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleasePd); + assert!(!control + .services + .iter() + .any( + |r| serde_json::from_value::(r.payload.clone()).is_ok_and( + |r| matches!(r.command, PhotodiodeCommandV1::FinalizeRecording { .. }) + ) + )); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("test reason")); + } + + fn plugin_at_finalization( + label: &str, + policy: TriggerValidation, + ) -> (StageAA2Plugin, MockControl) { + let mut plugin = ready_plugin(label); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let run = plugin.run.as_mut().unwrap(); + run.index = 1; + run.run_id = format!("{}_r002_step", run.measurement_id); + run.protocol.trigger_validation = policy; + run.phase = Phase::StopCamera; + run.pending = None; + run.camera_recording = true; + run.mod_leased = true; + run.pd_leased = true; + run.evidence.pdq_marker_counts = Some(stage_a_plugin_contract::PdqMarkerCountsV1 { + comparator_rising: 2, + comparator_falling: 2, + phase_zero: 2, + invalid_level: 0, + }); + (plugin, control) + } + + fn raw_finalized() -> HostCommandOutcome { + HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/tmp/a2-test.raw".into(), + size: 100, + sha256: "12".repeat(32), + duration_us: 4000, + } + } + + #[test] + fn a_bad_strict_point_never_advances_or_reports_success() { + let (mut plugin, mut control) = + plugin_at_finalization("strict-failure", TriggerValidation::Strict); + plugin.finish_point(&mut control, &raw_finalized()); + assert_eq!(plugin.run.as_ref().unwrap().index, 1); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleasePd); + assert!(!plugin.run.as_ref().unwrap().evidence.valid); + assert!(plugin.run.as_ref().unwrap().abort_reason.is_some()); + plugin.finish_run(); + assert!(plugin.message.contains("A2 stopped"), "{}", plugin.message); + } + + #[test] + fn offline_capture_keeps_noisy_trigger_data_and_marks_it_for_review() { + let (mut plugin, mut control) = + plugin_at_finalization("offline-noise", TriggerValidation::OfflineReview); + plugin.finish_point(&mut control, &raw_finalized()); + assert_eq!(plugin.run.as_ref().unwrap().review_points, 1); + assert!(plugin.run.as_ref().unwrap().abort_reason.is_none()); + plugin.finish_run(); + assert!( + plugin.message.contains("require offline timing review"), + "{}", + plugin.message + ); + assert!(!plugin.message.contains("checks passed")); + } + + #[test] + fn file_corruption_stops_even_an_offline_capture() { + let (mut plugin, mut control) = + plugin_at_finalization("offline-corrupt", TriggerValidation::OfflineReview); + plugin.run.as_mut().unwrap().evidence.failure = Some("PDQ sample gap".into()); + plugin.finish_point(&mut control, &raw_finalized()); + assert_eq!(plugin.run.as_ref().unwrap().index, 1); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("PDQ sample gap")); + } + + #[test] + fn sidecar_write_failure_is_not_silently_ignored() { + let (mut plugin, mut control) = + plugin_at_finalization("sidecar-io", TriggerValidation::OfflineReview); + let blocked = Path::new(&plugin.output_folder).join("not-a-directory"); + std::fs::write(&blocked, b"blocked").unwrap(); + plugin.run.as_mut().unwrap().output_root = blocked; + plugin.finish_point(&mut control, &raw_finalized()); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("cannot save A2 sidecar")); + } + + #[test] + fn a_stop_timeout_does_not_repeat_the_stop_forever() { + let (mut plugin, mut control) = plugin_at_low_plateau("stop-timeout"); + plugin.fail(&mut control, "original plateau failure".into()); + plugin.run.as_mut().unwrap().pending.as_mut().unwrap().2 = 0; + plugin.drive(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleasePd); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("original plateau failure")); + assert!(!plugin.run.as_ref().unwrap().cleanup_failures.is_empty()); + } + + #[test] + fn drive_sync_has_no_pd_amplitude_or_dead_time_precondition() { + let mut plugin = ready_auto_plugin("drive-sync"); + let text = format!( + "timing_reference=\"drive_sync\"\ntrigger_validation=\"offline_review\"\n{}", + auto_protocol() + ); + std::fs::write(&plugin.protocol_path, text).unwrap(); + plugin.sensor = None; + plugin.photodiode.as_mut().unwrap().stream.level = None; + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply, applied_camera_outcome()); + 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::Prepare); + let request: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let ModulationCommandV1::PrepareA2 { configuration } = request.command else { + panic!("expected PrepareA2") + }; + assert_eq!( + configuration.timing_reference, + A2TimingReferenceV1::DriveSync + ); + assert_eq!(configuration.comparator_threshold_dac, 0); + assert_eq!(configuration.min_half_us, 0); + assert!(plugin + .run + .as_ref() + .unwrap() + .evidence + .threshold_measurement + .is_none()); + assert!(plugin + .run + .as_ref() + .unwrap() + .resolved + .pixel_dead_time_us + .is_none()); + } + + #[test] + fn voltage_above_dac_rail_is_not_rounded_back_into_range() { + assert!(threshold_code_for_volts(2.5001).is_err()); + } + + #[test] + fn blocked_drive_sham_instructions_do_not_open_the_optical_path() { + let mut p = paused_point(); + p.role = "blocked_drive_sham".into(); + let message = pause_message(&p); + assert!(message.contains("keep the optical path blocked")); + assert!(!message.contains("open the optical path")); + } + + #[test] + fn stopped_pd_stream_aborts_instead_of_saving_a_short_valid_file() { + let (mut plugin, mut control) = plugin_at_low_plateau("stalled-pd"); + let index = plugin + .photodiode + .as_ref() + .unwrap() + .stream + .sample_range + .unwrap() + .end_sample_index_exclusive; + let run = plugin.run.as_mut().unwrap(); + run.phase = Phase::Recording; + run.pd_recording = true; + run.pd_progress = Some((index, now_ms() - 6000)); + plugin.drive(&mut control); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("stopped advancing")); + } + + #[test] + fn exhausted_camera_stop_timeout_cannot_report_success() { + let (mut plugin, mut control) = plugin_at_low_plateau("camera-stop-timeout"); + let run = plugin.run.as_mut().unwrap(); + run.phase = Phase::StopCamera; + run.camera_stop_attempts = 3; + run.pending = Some((PendingKind::Host, 99, now_ms() - TIMEOUT_MS - 1)); + run.camera_recording = true; + plugin.drive(&mut control); + let run = plugin.run.as_ref().unwrap(); + assert!(run.stop); + assert!(run + .abort_reason + .as_ref() + .unwrap() + .contains("camera stop timed out")); + } + #[test] + fn drive_sync_starts_its_identifiable_pulse_train_after_both_recorders_open() { + let (mut plugin, mut control) = plugin_at_low_plateau("sync-onset"); + let run = plugin.run.as_mut().unwrap(); + run.protocol.timing_reference = A2TimingReferenceV1::DriveSync; + run.phase = Phase::Prepare; + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!( + plugin.run.as_ref().unwrap().phase, + Phase::QuietBeforeCapture + ); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Settle); + 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::StartCamera); + plugin.accepted(&mut control, PendingKind::Host, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::StartPd); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + let run = plugin.run.as_ref().unwrap(); + assert!(run.pd_recording && run.camera_recording); + assert_eq!(run.phase, Phase::StartStimulus); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Recording); + } + #[test] + fn metadata_is_saved_before_the_camera_is_started() { + let (mut plugin, mut control) = + plugin_at_finalization("initial-metadata", TriggerValidation::OfflineReview); + let run = plugin.run.as_mut().unwrap(); + run.phase = Phase::Settle; + run.pending = None; + run.deadline_ms = 0; + run.next_renew_ms = u64::MAX; + let path = Path::new(&plugin.output_folder) + .join(&run.measurement_id) + .join(format!("{}.a2.json", run.run_id)); + plugin.drive(&mut control); + assert!( + path.is_file(), + "point evidence must exist before acquisition" + ); + } + fn waiting_camera(label: &str) -> (StageAA2Plugin, MockControl) { + let (mut plugin, mut control) = + plugin_at_finalization(label, TriggerValidation::OfflineReview); + let run = plugin.run.as_mut().unwrap(); + run.phase = Phase::Settle; + run.pending = None; + run.deadline_ms = 0; + run.next_renew_ms = u64::MAX; + plugin.drive(&mut control); + (plugin, control) + } + + #[test] + fn camera_and_pd_use_the_same_frozen_root_even_if_the_owner_folder_changes() { + let (mut plugin, mut control) = waiting_camera("frozen-root"); + let HostCommand::StartRecording { + root_dir: Some(root), + base_path, + .. + } = control.hosts.last().unwrap().command.clone() + else { + panic!("missing explicit root") + }; + assert!(Path::new(&root).is_absolute()); + let raw = Path::new(&root).join(&base_path); + plugin.photodiode.as_mut().unwrap().data_dir = Some("/different/owner/folder".into()); + let id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + id, + HostCommandOutcome::RecordingStarted { + actual_raw_path: raw.to_string_lossy().into_owned(), + started_at: "now".into(), + }, + ); + let request: PhotodiodeRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let PhotodiodeCommandV1::BeginRecording { specification } = request.command else { + panic!("not a PD start") + }; + assert_eq!(specification.root_dir.as_deref(), Some(root.as_str())); + assert_eq!( + Path::new(&root).join(&specification.pdq_path).parent(), + raw.parent() + ); + let sidecar = raw.with_extension("a2.json"); + let value: Value = serde_json::from_slice(&std::fs::read(sidecar).unwrap()).unwrap(); + assert_eq!( + value["evidence"]["raw_path"], + raw.to_string_lossy().as_ref() + ); + assert_eq!(value["evidence"]["acquisition_complete"], false); + } + + #[test] + fn photodiode_paths_reported_relative_to_the_root_are_anchored_to_it() { + let (mut plugin, mut control) = waiting_camera("relative-pd-paths"); + let HostCommand::StartRecording { + root_dir: Some(root), + base_path, + .. + } = control.hosts.last().unwrap().command.clone() + else { + panic!("missing explicit root") + }; + let raw = Path::new(&root).join(&base_path); + let id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + id, + HostCommandOutcome::RecordingStarted { + actual_raw_path: raw.to_string_lossy().into_owned(), + started_at: "now".into(), + }, + ); + let request: PhotodiodeRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + let PhotodiodeCommandV1::BeginRecording { specification } = request.command else { + panic!("not a PD start") + }; + // The owner echoes the requested paths, which are relative to the root. + 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, + &started_pd_payload( + pd_id, + &run_id, + &specification.pdq_path, + &specification.sidecar_path, + ), + ); + let run = plugin.run.as_ref().unwrap(); + assert!(!run.stop, "{:?}", run.abort_reason); + assert_eq!(run.abort_reason, None); + let measurement_dir = run + .output_root + .join(&run.measurement_id) + .canonicalize() + .unwrap(); + for (recorded, requested) in [ + (&run.evidence.pdq_path, &specification.pdq_path), + (&run.evidence.pd_sidecar_path, &specification.sidecar_path), + ] { + let recorded = Path::new(recorded.as_deref().expect("path recorded")); + assert!(recorded.is_absolute(), "{}", recorded.display()); + assert_eq!( + recorded.parent().unwrap().canonicalize().unwrap(), + measurement_dir + ); + assert_eq!( + recorded.file_name(), + Path::new(requested).file_name(), + "{}", + recorded.display() + ); + } + } + + #[test] + fn an_old_host_ignoring_the_root_is_stopped_before_pd_start() { + let (mut plugin, mut control) = waiting_camera("wrong-host-root"); + let id = control.hosts.last().unwrap().request_id; + let wrong = test_folder("wrong-raw-location"); + std::fs::create_dir_all(&wrong).unwrap(); + plugin.host_reply( + &mut control, + id, + HostCommandOutcome::RecordingStarted { + actual_raw_path: wrong.join("recording.raw").to_string_lossy().into_owned(), + started_at: "now".into(), + }, + ); + assert!(plugin.run.as_ref().unwrap().stop); + assert!(plugin + .run + .as_ref() + .unwrap() + .abort_reason + .as_ref() + .unwrap() + .contains("outside the measurement folder")); + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::StopRecording + )); + assert!(!control + .services + .iter() + .any( + |r| serde_json::from_value::(r.payload.clone()) + .is_ok_and(|r| matches!(r.command, PhotodiodeCommandV1::BeginRecording { .. })) + )); + } + + #[test] + fn rejected_camera_start_does_not_stop_an_unrelated_recording() { + let (mut plugin, mut control) = waiting_camera("camera-busy"); + let id = control.hosts.last().unwrap().request_id; + let host_count = control.hosts.len(); + plugin.host_reply( + &mut control, + id, + HostCommandOutcome::Rejected { + code: "recording_busy".into(), + message: "another recording is active".into(), + }, + ); + assert!(!plugin.run.as_ref().unwrap().camera_recording); + assert!(!control.hosts[host_count..] + .iter() + .any(|r| matches!(r.command, HostCommand::StopRecording))); + } + + #[test] + fn measurement_id_is_preserved_and_repeated_runs_do_not_overwrite() { + let mut plugin = ready_plugin("repeat-id"); + plugin + .set_setting("measurement_id", json!("A2-Atto647-test")) + .unwrap(); + let mut control = MockControl::default(); + plugin.begin(&mut control); + plugin.prepare(&mut control); + let first = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.write_sidecar().unwrap(); + let path = plugin + .run + .as_ref() + .unwrap() + .output_root + .join(&plugin.measurement_id) + .join(format!("{first}.a2.json")); + plugin.finish_run(); + let before = std::fs::read(&path).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(2)); + plugin.begin(&mut control); + plugin.prepare(&mut control); + plugin.write_sidecar().unwrap(); + assert_eq!(plugin.measurement_id, "A2-Atto647-test"); + assert_ne!(plugin.run.as_ref().unwrap().run_id, first); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + + #[test] + fn unsafe_or_reserved_measurement_ids_refuse_before_hardware() { + for (i, id) in ["../escape", "a/b", "C:\\data", "CON", "LPT1", "nul"] + .iter() + .enumerate() + { + let mut plugin = ready_plugin(&format!("id-validation-{i}")); + plugin.measurement_id = (*id).into(); + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(plugin.run.is_none(), "accepted {id}"); + assert!(control.hosts.is_empty() && control.services.is_empty()); + } + } + + #[test] + fn active_measurement_settings_are_frozen() { + let (mut plugin, _) = waiting_camera("settings-frozen"); + for key in ["measurement_id", "output_folder", "protocol_path", "new_id"] { + assert!(plugin.set_setting(key, json!("replacement")).is_err()); + } + let id = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.reset(); + assert_eq!( + plugin.run.as_ref().unwrap().run_id, + id, + "host reset lost active acquisition" + ); + } + + #[test] + fn rest_time_counts_down_only_timed_phases_and_excludes_pauses() { + let mut plugin = ready_plugin("eta"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let run = plugin.run.as_mut().unwrap(); + let expected: f64 = run.protocol.points.iter().map(point_seconds).sum(); + run.phase = Phase::Paused; + assert_eq!(remaining_seconds(run, 0), expected); + assert_eq!(remaining_seconds(run, 1_000_000), expected); + run.phase = Phase::Recording; + run.deadline_ms = 500; + let later = point_seconds(&run.protocol.points[1]); + assert!((remaining_seconds(run, 100) - (0.4 + later)).abs() < 1e-9); + assert!((remaining_seconds(run, 400) - (0.1 + later)).abs() < 1e-9); + run.phase = Phase::RestoreCamera; + assert_eq!(remaining_seconds(run, 0), 0.0); + } + + #[test] + fn loading_a_protocol_shows_its_duration_before_start() { + let mut plugin = ready_plugin("preview-estimate"); + plugin + .set_setting("protocol_path", json!(plugin.protocol_path)) + .unwrap(); + let status = format!("{:?}", plugin.status_entries()); + assert!( + status.contains("2 points") && status.contains("manual pauses"), + "{status}" + ); + assert!(plugin.run.is_none()); + } + + #[test] + fn pending_modulation_is_polled_with_unchanged_identity_and_timeout() { + let mut plugin = ready_plugin("poll-command"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, id, applied_camera_outcome()); + let original = control.services.last().unwrap().clone(); + let pending = plugin.run.as_ref().unwrap().pending; + plugin.run.as_mut().unwrap().last_mod_poll_ms = 0; + plugin.finish_async_mod(&mut control); + let poll = control.services.last().unwrap(); + assert_eq!(poll.request_id, original.request_id); + assert_eq!(poll.payload, original.payload); + assert_eq!(plugin.run.as_ref().unwrap().pending, pending); + let count = control.services.len(); + plugin.finish_async_mod(&mut control); + assert_eq!(control.services.len(), count, "poll must be throttled"); + } + + #[test] + fn another_clients_snapshot_cannot_abort_a_pending_command() { + use stage_a_plugin_contract::{ + ControllerStateV1, OwnerInstanceId, ResponseCommonV1, CONTRACT_VERSION_V1, + }; + let mut plugin = ready_plugin("snapshot-collision"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, id, applied_camera_outcome()); + let pending = plugin.run.as_ref().unwrap().pending; + let response = ModulationResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: RequestId(pending.unwrap().1), + owner_instance: OwnerInstanceId::new( + plugin + .run + .as_ref() + .unwrap() + .resolved + .modulation_owner_instance + .clone(), + ), + run_id: Some(RunId::new("different-workflow")), + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_ms()), + error: None, + }, + controller_state: ControllerStateV1::Running, + acknowledged_target: None, + marker_diagnostics: None, + }; + plugin.modulation.as_mut().unwrap().last_response = Some(response); + plugin.finish_async_mod(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().pending, pending); + assert!(!plugin.run.as_ref().unwrap().stop); + let response = plugin + .modulation + .as_mut() + .unwrap() + .last_response + .as_mut() + .unwrap(); + response.common.run_id = Some(RunId::new( + plugin.run.as_ref().unwrap().lease_run_id.clone(), + )); + plugin.finish_async_mod(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::AcquirePd); + } + + #[test] + fn completed_capture_and_progress_survive_cleanup() { + let (mut plugin, mut control) = + plugin_at_finalization("progress-capture", TriggerValidation::OfflineReview); + plugin.finish_point(&mut control, &raw_finalized()); + let run = plugin.run.as_ref().unwrap(); + let sidecar = run + .output_root + .join(&run.measurement_id) + .join(format!("{}.a2.json", run.run_id)); + let journal = run + .output_root + .join(&run.measurement_id) + .join(format!("{}_progress.jsonl", run.attempt_id)); + assert_eq!(run.completed_points, 1); + plugin.finish_run(); + let value: Value = serde_json::from_slice(&std::fs::read(sidecar).unwrap()).unwrap(); + assert_eq!(value["evidence"]["acquisition_complete"], true); + assert_eq!(value["protocol_row"], 2); + let events: Vec = std::fs::read_to_string(journal) + .unwrap() + .lines() + .map(|s| serde_json::from_str(s).unwrap()) + .collect(); + assert!(events.iter().any(|v| v["event"] == "point_finished")); + assert_eq!(events.last().unwrap()["event"], "run_finished"); + assert_eq!(events.last().unwrap()["completed_points"], 1); + } + + #[cfg(unix)] + #[test] + fn symlinked_measurement_directory_refuses_before_hardware() { + let mut plugin = ready_plugin("symlink-directory"); + let outside = test_folder("outside-directory"); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink( + &outside, + Path::new(&plugin.output_folder).join(&plugin.measurement_id), + ) + .unwrap(); + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(plugin.run.is_none()); + assert!(control.hosts.is_empty()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); + } + fn save_completed_row(plugin: &mut StageAA2Plugin, index: usize) { + let run = plugin.run.as_mut().unwrap(); + run.index = index; + run.run_id = format!("{}_r{:03}_saved", run.measurement_id, index + 1); + let dir = run.output_root.join(&run.measurement_id); + let mut paths = Vec::new(); + for suffix in ["raw", "toml", "pdq", "pd.json"] { + let name = format!("{}.{}", run.run_id, suffix); + std::fs::write(dir.join(&name), b"recorded").unwrap(); + paths.push(format!(r"C:\old\{name}")); + } + run.evidence.raw_path = Some(paths[0].clone()); + run.evidence.camera_configuration_sidecar_path = Some(paths[1].clone()); + run.evidence.pdq_path = Some(paths[2].clone()); + run.evidence.pd_sidecar_path = Some(paths[3].clone()); + run.evidence.acquisition_complete = true; + plugin.write_sidecar().unwrap(); + } + + #[test] + fn resume_requires_same_protocol_and_complete_local_artifacts() { + let mut plugin = ready_plugin("resume-evidence"); + plugin.begin(&mut MockControl::default()); + save_completed_row(&mut plugin, 1); + let run = plugin.run.as_ref().unwrap(); + let dir = run.output_root.join(&run.measurement_id); + let scan = || { + crate::resume::completed( + &dir, + &run.measurement_id, + &run.protocol_sha256, + &run.protocol, + ) + .unwrap() + }; + assert_eq!(scan(), BTreeSet::from([1])); + assert!( + crate::resume::completed(&dir, &run.measurement_id, "changed", &run.protocol) + .unwrap() + .is_empty() + ); + let pd = dir.join(format!("{}.pdq", run.run_id)); + std::fs::write(&pd, b"").unwrap(); + assert!(scan().is_empty()); + std::fs::remove_file(pd).unwrap(); + assert!(scan().is_empty()); + } + + #[test] + fn resume_skips_gaps_without_renumbering_and_all_complete_needs_no_hardware() { + let mut plugin = ready_plugin("resume-gaps"); + plugin.begin(&mut MockControl::default()); + save_completed_row(&mut plugin, 1); + // Remove the current journal so this test can restart within the same millisecond. + let run = plugin.run.take().unwrap(); + std::fs::remove_file( + run.output_root + .join(&run.measurement_id) + .join(format!("{}_progress.jsonl", run.attempt_id)), + ) + .unwrap(); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let run = plugin.run.as_ref().unwrap(); + assert_eq!(run.index, 0); + assert_eq!(run.resumed_points, BTreeSet::from([1])); + assert_eq!( + remaining_seconds(run, 0), + point_seconds(&run.protocol.points[0]) + ); + assert!(run.resume_pause); + save_completed_row(&mut plugin, 0); + let run = plugin.run.take().unwrap(); + std::fs::remove_file( + run.output_root + .join(&run.measurement_id) + .join(format!("{}_progress.jsonl", run.attempt_id)), + ) + .unwrap(); + plugin.modulation = None; + plugin.sensor = None; + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(plugin.run.is_none(), "{}", plugin.message); + assert!( + plugin.message.contains("already complete"), + "{}", + plugin.message + ); + assert!(control.hosts.is_empty() && control.services.is_empty()); + } + + #[test] + fn stop_during_camera_start_does_not_start_photodiode() { + let (mut plugin, mut control) = waiting_camera("stop-opening"); + plugin.stop_pending = true; + let run = plugin.run.as_ref().unwrap(); + let path = run + .output_root + .join(&run.measurement_id) + .join(format!("{}.raw", run.run_id)); + let id = control.hosts.last().unwrap().request_id; + let services_before = control.services.len(); + plugin.host_reply( + &mut control, + id, + HostCommandOutcome::RecordingStarted { + actual_raw_path: path.to_string_lossy().into_owned(), + started_at: "now".into(), + }, + ); + assert_eq!(control.services.len(), services_before); + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::StopRecording + )); + assert!(!plugin.run.as_ref().unwrap().evidence.acquisition_complete); + } + #[test] + fn advancing_skips_completed_rows_and_keeps_crossed_manual_pause() { + let mut plugin = ready_plugin("resume-advance"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let run = plugin.run.as_mut().unwrap(); + run.protocol.points.push(run.protocol.points[1].clone()); + run.protocol.points[1].pause_before = true; + run.protocol.points[2].pause_before = false; + run.resumed_points = BTreeSet::from([1]); + run.index = 0; + run.pending = None; + plugin.advance(&mut control); + let run = plugin.run.as_ref().unwrap(); + assert_eq!(run.index, 2); + assert_eq!(run.completed_points, 1); + assert_eq!(run.phase, Phase::Paused); + assert!(run.run_id.contains("_r003_")); + } + #[test] + fn ui_mirror_keeps_continue_and_stop_accessible_without_worker_run_state() { + let mut mirror = StageAA2Plugin::default(); + mirror.set_runtime_role(PluginRuntimeRole::UiMirror); + assert!(mirror.run.is_none()); + let schema = mirror.settings_schema(); + for key in ["continue_run", "stop_protocol"] { + let item = schema + .sections + .iter() + .flat_map(|s| &s.items) + .find(|item| item.key == key) + .unwrap(); + assert!( + matches!(item.kind, SettingKind::Button { enabled: true }), + "{key} must be accessible in the UI mirror" + ); + } + } + + #[test] + fn continue_click_outside_a_pause_cannot_acknowledge_a_later_pause() { + let mut plugin = ready_plugin("continue-interlock"); + let mut control = MockControl::default(); + plugin.set_setting("continue_run", json!(1)).unwrap(); + plugin.drive(&mut control); + assert!(!plugin.continue_pending); + assert!(control.hosts.is_empty() && control.services.is_empty()); + plugin.begin(&mut control); + let run = plugin.run.as_mut().unwrap(); + run.pending = None; + run.next_renew_ms = u64::MAX; + plugin.prepare(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Paused); + plugin.set_setting("continue_run", json!(2)).unwrap(); + plugin.drive(&mut control); + assert_ne!(plugin.run.as_ref().unwrap().phase, Phase::Paused); + assert!(plugin.run.as_ref().unwrap().pause_acknowledged); + } +} + +export_plugin!(StageAA2Plugin); 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..bde6aff --- /dev/null +++ b/plugins/stage-a-a4/src/runtime.rs @@ -0,0 +1,3305 @@ +//! 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"), + root_dir: None, + 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, +} diff --git a/plugins/stage-a-modulation/Cargo.toml b/plugins/stage-a-modulation/Cargo.toml index 57207f9..49c3348 100644 --- a/plugins/stage-a-modulation/Cargo.toml +++ b/plugins/stage-a-modulation/Cargo.toml @@ -15,3 +15,4 @@ serde_json.workspace = true stage-a-io = { path = "../../stage-a-io" } stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } toml = "0.8" + diff --git a/plugins/stage-a-modulation/README.md b/plugins/stage-a-modulation/README.md index a3ae241..6bac4a9 100644 --- a/plugins/stage-a-modulation/README.md +++ b/plugins/stage-a-modulation/README.md @@ -102,10 +102,14 @@ inversion they used. Full detail: [feature brief](../../docs/features/stage-a-po ## Ports -**Use `auto` (default recommendation):** it probes every attached usbmodem/ttyACM device and -connects to the one that answers `HELLO` — that is always the Teensy command port, never the -photodiode stream port. Explicit ports remain selectable; `mock` runs an in-process simulated -controller for hardware-free testing. +**Use `auto` (default recommendation):** it probes every attached USB serial port and connects to +the one that answers `HELLO` — that is always the Teensy command port, never the photodiode +stream port. Explicit ports remain selectable; `mock` runs an in-process simulated controller for +hardware-free testing. + +Which ports get probed is platform-specific: `cu.usbmodem*` on macOS, `ttyACM*` on Linux, and +every USB-classified `COMn` on Windows (ADR 032). The picker lists the same set with each port's +USB label, so the Teensy is recognisable by name. Replaying a recording disconnects the plugin defensively; live control itself needs no capture session. diff --git a/plugins/stage-a-modulation/src/lib.rs b/plugins/stage-a-modulation/src/lib.rs index 8dbf1b7..50e0a87 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,15 +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, PLUGIN_ID_STAGE_A_MODULATION, PLUGIN_ID_STAGE_A_PHOTODIODE, - SERVICE_STAGE_A_MODULATION_CONTROL_V1, + A2AcquisitionConfigV1, A2TimingReferenceV1, ClientId, ConnectionStateV1, ControllerStateV1, + FreshnessV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, + ModulationResponseV1, ModulationStateV1, ModulationTargetV1, OpticalDriveStateV1, + OpticalLobeStateV1, 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"; @@ -269,10 +273,19 @@ struct DeviceState { board_freq_millihz: Option, last_error: Option, controller_state: ControllerStateV1, + /// Acquisition settings echoed by `STATUS`/`CONFIG`. `CONFIG` needs a mode + /// *and* a rate, so a mode change has to restate the rest of the + /// controller's configuration rather than invent it. + controller_mode: Option, + controller_rate_hz: Option, + controller_block_samples: Option, + controller_emit_raw: Option, + controller_emit_summary: Option, requested: Option, acknowledged: Option, last_response: Option, last_device_update_unix_ms: u64, + marker_diagnostics: Option, } impl Default for DeviceState { @@ -281,6 +294,11 @@ impl Default for DeviceState { connected: false, firmware: String::new(), capabilities: Vec::new(), + controller_mode: None, + controller_rate_hz: None, + controller_block_samples: None, + controller_emit_raw: None, + controller_emit_summary: None, board_code: None, board_mod: String::new(), board_wave: None, @@ -293,6 +311,7 @@ impl Default for DeviceState { acknowledged: None, last_response: None, last_device_update_unix_ms: 0, + marker_diagnostics: None, } } } @@ -326,7 +345,7 @@ impl DeviceState { Some(ModulationTargetV1 { 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, @@ -337,6 +356,7 @@ impl DeviceState { #[derive(Clone)] struct OperationMeta { request_id: stage_a_plugin_contract::RequestId, + requester: ClientId, run_id: Option, requested_revision: SemanticRevision, target: ModulationTargetV1, @@ -356,6 +376,8 @@ struct SharedLink { /// slider drags coalesce instead of queueing. pending: Mutex>, priority: Mutex>, + automation: Mutex>, + completions: Mutex>, stop: AtomicBool, fail_closed_on_stop: AtomicBool, generation: AtomicU64, @@ -367,6 +389,8 @@ impl SharedLink { state: Mutex::new(DeviceState::default()), pending: Mutex::new(None), priority: Mutex::new(None), + automation: Mutex::new(VecDeque::new()), + completions: Mutex::new(VecDeque::new()), stop: AtomicBool::new(false), fail_closed_on_stop: AtomicBool::new(false), generation: AtomicU64::new(1), @@ -472,7 +496,15 @@ fn run_device(mut client: StageAClient, shared: Arc let mut consecutive_errors = 0u32; while !shared.stop.load(Ordering::Relaxed) { let priority = shared.priority.lock().expect("priority lock").take(); - let pending = priority.or_else(|| shared.pending.lock().expect("pending lock").take()); + let pending = priority + .or_else(|| { + shared + .automation + .lock() + .expect("automation lock") + .pop_front() + }) + .or_else(|| shared.pending.lock().expect("pending lock").take()); if let Some(operation) = pending { if execute_operation(&mut client, &shared, operation) { consecutive_errors = 0; @@ -525,6 +557,7 @@ fn execute_operation( shared: &SharedLink, operation: PendingOperation, ) -> bool { + let requester = operation.meta.as_ref().map(|meta| meta.requester.clone()); let mut merged = BTreeMap::new(); let mut error = None; for command in &operation.commands { @@ -540,12 +573,48 @@ fn execute_operation( } } + // Snapshot the firmware's loss counters at A2 command boundaries. USB + // frame CRCs cannot reveal a marker dropped before it was serialized. + if error.is_none() + && operation + .meta + .as_ref() + .is_some_and(|m| m.target.a2_configuration.is_some()) + { + match client.request(&Command::new("STATUS")) { + Ok(fields) => merged.extend(fields), + Err(e) => error = Some(format!("A2 status readback failed: {e}")), + } + } + + if error.is_none() && operation.purpose == "PREPARE_A2" { + let reference = operation + .meta + .as_ref() + .and_then(|m| m.target.a2_configuration.as_ref()) + .map_or(A2TimingReferenceV1::Comparator, |c| c.timing_reference); + if let Err(reason) = verify_a2_prepared(&merged, reference) { + error = Some(reason); + } + } + + if error.is_none() + && operation.purpose == "PREPARE_A1" + && (merged.get("mode").map(String::as_str) != Some("A1") + || merged.get("state").map(String::as_str) != Some("RUNNING") + || merged.get("trigger_source").map(String::as_str) != Some("J24_PHASE0") + || merged.get("cmp_armed").map(String::as_str) != Some("0")) + { + error = Some("A1 preparation not confirmed: expected mode=A1, running acquisition, J24_PHASE0 and comparator off".into()); + } + let mut state = shared.state.lock().expect("device state lock"); let succeeded = error.is_none(); if let Some(message) = error { state.last_error = Some(format!("{}: {message}", operation.purpose)); if let Some(meta) = operation.meta { state.last_response = Some(ModulationResponseV1 { + marker_diagnostics: state.marker_diagnostics, common: ResponseCommonV1 { contract_version: CONTRACT_VERSION_V1, request_id: meta.request_id, @@ -570,6 +639,10 @@ fn execute_operation( state.last_error = None; if let Some(meta) = operation.meta { let mut acknowledged = meta.target; + if operation.purpose == "MOD" { + acknowledged.waveform = + state.board_echo_target().and_then(|target| target.waveform); + } acknowledged.board_dac_code = state.board_code.and_then(|code| u16::try_from(code).ok()); acknowledged.firmware_configuration_revision = merged @@ -577,6 +650,7 @@ fn execute_operation( .and_then(|value| value.parse::().ok()); state.acknowledged = Some(acknowledged.clone()); state.last_response = Some(ModulationResponseV1 { + marker_diagnostics: state.marker_diagnostics, common: ResponseCommonV1 { contract_version: CONTRACT_VERSION_V1, request_id: meta.request_id, @@ -593,6 +667,15 @@ fn execute_operation( }); } } + if let Some(requester) = requester { + if let Some(response) = state.last_response.clone() { + let mut completions = shared.completions.lock().expect("completions lock"); + completions.push_back((requester, response)); + while completions.len() > 128 { + completions.pop_front(); + } + } + } state.last_device_update_unix_ms = now_unix_ms(); drop(state); shared.bump(); @@ -620,9 +703,42 @@ fn apply_status_reply( } fn apply_reply_fields(state: &mut DeviceState, fields: &BTreeMap) { + if let (Some(marker_drops), Some(stream_marker_drops)) = ( + fields.get("marker_drops").and_then(|v| v.parse().ok()), + fields + .get("stream_marker_drops") + .and_then(|v| v.parse().ok()), + ) { + state.marker_diagnostics = Some(stage_a_plugin_contract::A2MarkerDiagnosticsV1 { + dma_sample_clock: fields.get("marker_clock").map(String::as_str) + == Some("dma_cursor_v1"), + marker_drops, + stream_marker_drops, + observed_at_unix_ms: now_unix_ms(), + }); + } + if let Some(code) = fields.get("code").and_then(|v| v.parse::().ok()) { state.board_code = Some(code); } + if let Some(mode) = fields.get("mode") { + state.controller_mode = Some(mode.clone()); + } + if let Some(rate) = fields.get("rate_hz").and_then(|v| v.parse::().ok()) { + state.controller_rate_hz = Some(rate); + } + if let Some(block) = fields + .get("block_samples") + .and_then(|v| v.parse::().ok()) + { + state.controller_block_samples = Some(block); + } + if let Some(raw) = fields.get("raw") { + state.controller_emit_raw = Some(raw == "1"); + } + if let Some(summary) = fields.get("summary") { + state.controller_emit_summary = Some(summary == "1"); + } if let Some(controller) = fields.get("state") { state.controller_state = match controller.as_str() { "SAFE_IDLE" => ControllerStateV1::SafeIdle, @@ -1251,12 +1367,29 @@ impl StageAModulationPlugin { }) } + /// Publishes the applied calibration independently of the currently armed + /// mode and point. A protocol runner owns its measurement points and needs + /// only these calibrated lobe endpoints to construct them. + fn optical_lobe_state(&self) -> Option { + let calibration_id = self.calibration_id.clone()?; + let inversion = self.resolved_lobe().ok()?.inversion; + Some(OpticalLobeStateV1 { + calibration_id, + v_null_dac: u16::try_from(inversion.dac_for_u(0.0).round() as i64).ok()?, + v_peak_dac: u16::try_from(inversion.dac_for_u(1.0).round() as i64).ok()?, + }) + } + /// Builds the single MOD command carrying the complete current drive /// settings (mode, method, band, frequency). Shared by the operator path /// (`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") @@ -1792,7 +1925,7 @@ impl StageAModulationPlugin { .unwrap_or(ModulationTargetV1 { revision, waveform: None, - a1_configuration: None, + a2_configuration: None, acquisition_running: false, board_dac_code: None, firmware_configuration_revision: None, @@ -1803,6 +1936,28 @@ impl StageAModulationPlugin { target } + /// The controller's own acquisition settings, as it last reported them. + /// + /// A mode change restates them, so a controller that has not reported yet + /// is refused rather than reconfigured from a guess. + fn controller_acquisition(&self) -> Result { + let missing = || { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "the controller has not reported its acquisition settings yet — connect and let \ + it answer STATUS first", + true, + ) + }; + let state = self.shared.state.lock().expect("device state lock"); + Ok(ControllerAcquisition { + rate_hz: state.controller_rate_hz.ok_or_else(missing)?, + block_samples: state.controller_block_samples.ok_or_else(missing)?, + emit_raw: state.controller_emit_raw.unwrap_or(true), + emit_summary: state.controller_emit_summary.unwrap_or(true), + }) + } + fn queue_service_operation( &mut self, request: &ModulationRequestV1, @@ -1818,9 +1973,18 @@ impl StageAModulationPlugin { true, )); } + let mut automation = self.shared.automation.lock().expect("automation lock"); + if !priority && automation.len() >= 64 { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "controller command queue is full", + true, + )); + } let revision = target.revision; let meta = OperationMeta { request_id: request.request_id, + requester: request.requester.clone(), run_id: request.run_id.clone(), requested_revision: revision, target: target.clone(), @@ -1836,13 +2000,16 @@ impl StageAModulationPlugin { meta: Some(meta), }; if priority { + automation.clear(); *self.shared.pending.lock().expect("pending lock") = None; *self.shared.priority.lock().expect("priority lock") = Some(operation); } else { - *self.shared.pending.lock().expect("pending lock") = Some(operation); + automation.push_back(operation); } + drop(automation); self.shared.bump(); Ok(ModulationResponseV1 { + marker_diagnostics: None, common: ResponseCommonV1 { contract_version: CONTRACT_VERSION_V1, request_id: request.request_id, @@ -1971,11 +2138,11 @@ impl StageAModulationPlugin { self.deferred_release_ack_published = false; return Ok(response); } - self.end_lease(); - self.deferred_release_request = None; self.shared .fail_closed_on_stop .store(false, Ordering::Relaxed); + self.end_lease(); + self.deferred_release_request = None; self.immediate_response(request, RequestOutcomeV1::Applied, None) } ModulationCommandV1::SafeOff { reason } => { @@ -2067,13 +2234,18 @@ impl StageAModulationPlugin { // sweep point, so `end_lease` can hand it back. Only the // first one: later points must not overwrite the original. self.armed_depth_a.get_or_insert(previous); - *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { - commands: vec![command], - purpose: "MOD", - meta: None, - }); - self.shared.bump(); - self.immediate_response(request, RequestOutcomeV1::Applied, None) + let revision = self + .shared + .state + .lock() + .expect("device state lock") + .requested + .as_ref() + .map_or(SemanticRevision(1), |target| { + SemanticRevision(target.revision.0 + 1) + }); + let target = self.base_target(revision); + self.queue_service_operation(request, target, vec![command], "MOD", false) } ModulationCommandV1::SetDriveFrequency { frequency_millihz } => { self.require_lease(request)?; @@ -2087,12 +2259,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, )); @@ -2124,13 +2297,18 @@ impl StageAModulationPlugin { // first retarget only, so `end_lease` hands back what they // armed rather than the sweep's last point. self.armed_frequency_hz.get_or_insert(previous); - *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { - commands: vec![command], - purpose: "MOD", - meta: None, - }); - self.shared.bump(); - self.immediate_response(request, RequestOutcomeV1::Applied, None) + let revision = self + .shared + .state + .lock() + .expect("device state lock") + .requested + .as_ref() + .map_or(SemanticRevision(1), |target| { + SemanticRevision(target.revision.0 + 1) + }); + let target = self.base_target(revision); + self.queue_service_operation(request, target, vec![command], "MOD", false) } ModulationCommandV1::SetOperatingPoint { mean_u_milli } => { self.require_lease(request)?; @@ -2184,31 +2362,49 @@ impl StageAModulationPlugin { // the first retarget only, so `end_lease` hands back what they // armed rather than the sweep's last point. self.armed_operating_point.get_or_insert(previous); - *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { - commands: vec![command], - purpose: "MOD", - meta: None, - }); - self.shared.bump(); - self.immediate_response(request, RequestOutcomeV1::Applied, None) + let revision = self + .shared + .state + .lock() + .expect("device state lock") + .requested + .as_ref() + .map_or(SemanticRevision(1), |target| { + SemanticRevision(target.revision.0 + 1) + }); + let target = self.base_target(revision); + self.queue_service_operation(request, target, vec![command], "MOD", false) } - ModulationCommandV1::PrepareA1 { configuration } => { + ModulationCommandV1::PrepareA1 => { self.require_lease(request)?; + let settings = self.controller_acquisition()?; let revision = self.requested_revision(request)?; let mut target = self.base_target(revision); - target.a1_configuration = Some(configuration.clone()); - target.acquisition_running = false; + target.a2_configuration = None; + target.acquisition_running = true; self.queue_service_operation( request, target, - vec![ - Command::new("STOP").field("reason", "prepare_a1"), - a1_config_command(configuration), - ], + a1_prepare_commands(&settings), "PREPARE_A1", 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.a2_configuration = Some(configuration.clone()); + target.acquisition_running = false; + self.queue_service_operation( + request, + target, + a2_prepare_commands(configuration)?, + "PREPARE_A2", + false, + ) + } ModulationCommandV1::StartAcquisition => { self.require_lease(request)?; let revision = self.requested_revision(request)?; @@ -2246,6 +2442,7 @@ impl StageAModulationPlugin { ) -> Result { let state = self.shared.state.lock().expect("device state lock"); let response = ModulationResponseV1 { + marker_diagnostics: state.marker_diagnostics, common: ResponseCommonV1 { contract_version: CONTRACT_VERSION_V1, request_id: request.request_id, @@ -2315,6 +2512,7 @@ impl StageAModulationPlugin { capabilities: state.capabilities.clone(), lease: self.lease_snapshot(), controller_state: state.controller_state, + controller_mode: state.controller_mode.clone(), active_run_id: self.lease.as_ref().and_then(|lease| lease.run_id.clone()), requested: state.requested.clone(), // Service-path acknowledgements win; otherwise expose the @@ -2336,6 +2534,7 @@ impl StageAModulationPlugin { valid_for_ms: 1_500, }, calibration_id: self.calibration_id.clone(), + optical_lobe: self.optical_lobe_state(), optical_drive: self.optical_drive_state(), } } @@ -2348,6 +2547,11 @@ impl StageAModulationPlugin { /// rather than what the operator had armed. The calibration sweep already /// restores through `Sweep::restore`; this is the leased equivalent. fn end_lease(&mut self) { + self.shared + .automation + .lock() + .expect("automation lock") + .clear(); self.lease = None; let depth = self.armed_depth_a.take(); let frequency = self.armed_frequency_hz.take(); @@ -2361,7 +2565,9 @@ impl StageAModulationPlugin { if let Some(operating_point) = operating_point { self.operating_point = operating_point; } - if depth.is_some() || frequency.is_some() || operating_point.is_some() { + if (depth.is_some() || frequency.is_some() || operating_point.is_some()) + && !self.shared.fail_closed_on_stop.load(Ordering::Relaxed) + { // Re-arm the board only if nobody else now owns the DAC; // `send_modulation` is itself guarded. self.send_modulation(); @@ -2379,6 +2585,11 @@ impl StageAModulationPlugin { self.shared .fail_closed_on_stop .store(true, Ordering::Relaxed); + self.shared + .automation + .lock() + .expect("automation lock") + .clear(); *self.shared.pending.lock().expect("pending lock") = None; *self.shared.priority.lock().expect("priority lock") = Some(PendingOperation { commands: vec![ @@ -2680,7 +2891,7 @@ fn open_serial(port_hint: &str) -> Result Command { } } -fn a1_config_command(configuration: &A1AcquisitionConfigV1) -> Command { +/// `CONFIG mode=A1` — the trigger policy an A1 run needs, with the +/// controller's own acquisition settings restated unchanged. +/// +/// In A1 the firmware parks the comparator and stamps a phase-0 marker on the +/// photodiode stream at every cycle; in A2 the comparator drives the camera +/// trigger instead and no phase-0 marker is written. `CONFIG` accepts only +/// `mode`, `rate_hz`, `block_samples`, `raw` and `summary` — anything else is +/// answered with `SYNTAX unknown_config_field` — and it requires a mode *and* a +/// rate, so the mode cannot be changed without restating the rate. +/// The whole A1 preparation sequence, sent every time preparation is asked for. +/// +/// A controller that *reports* `A1` has not necessarily been configured for A1: +/// A2's drive-synchronized capture configures A1 mode itself, at A2's own +/// sample rate, and the firmware releases an armed comparator only when a +/// `CONFIG` leaves A2. Restating the configuration costs one stream restart per +/// run and leaves the caller nothing to inherit. `CONFIG` is refused while the +/// acquisition runs, so `STOP` comes first; the closing `STATUS` is what the +/// readback check reads mode, state, trigger source and comparator from. +fn a1_prepare_commands(settings: &ControllerAcquisition) -> Vec { + vec![ + Command::new("STOP").field("reason", "prepare_a1"), + a1_config_command(settings), + Command::new("START"), + Command::new("STATUS"), + ] +} + +fn a1_config_command(settings: &ControllerAcquisition) -> Command { Command::new("CONFIG") .field("mode", "A1") + .field("rate_hz", settings.rate_hz) + .field("block_samples", settings.block_samples) + .field("raw", u8::from(settings.emit_raw)) + .field("summary", u8::from(settings.emit_summary)) +} + +/// The acquisition settings a mode change has to carry over unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ControllerAcquisition { + rate_hz: u32, + block_samples: u32, + emit_raw: bool, + emit_summary: bool, +} + +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 configuration.timing_reference == A2TimingReferenceV1::Comparator + && !(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 verify_a2_prepared( + fields: &BTreeMap, + reference: A2TimingReferenceV1, +) -> Result<(), String> { + let source = fields.get("trigger_source").map(String::as_str); + let wave = fields.get("mod_wave").map(String::as_str); + let comparator = fields.get("cmp_armed").map(String::as_str); + let valid = match reference { + A2TimingReferenceV1::Comparator => { + matches!(source, Some("PD_COMPARATOR" | "comparator")) + && comparator == Some("1") + && wave == Some("LOG_SQUARE") + } + A2TimingReferenceV1::DriveSync => { + source == Some("J24_PHASE0") && comparator == Some("0") && wave == Some("SQUARE") + } + }; + if valid { + Ok(()) + } else { + Err(format!("A2 {:?} preparation not confirmed: trigger_source={source:?}, cmp_armed={comparator:?}, mod_wave={wave:?}", reference)) + } +} + +fn a2_prepare_commands(c: &A2AcquisitionConfigV1) -> Result, ServiceErrorV1> { + let mut commands = vec![ + Command::new("STOP").field("reason", "prepare_a2"), + a2_config_command(c), + ]; + match c.timing_reference { + A2TimingReferenceV1::Comparator => { + commands.push(a2_comparator_command(c)); + commands.push(a2_log_square_command(c)); + } + A2TimingReferenceV1::DriveSync => { + let mean = f64::from(c.mean_u_milli) / 1000.0; + let depth = f64::from(c.depth_a_milli) / 1000.0; + let high = mean * (0.5 * depth).exp(); + let low = mean * (-0.5 * depth).exp(); + if !high.is_finite() || high > 1.0 { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "A2 square exceeds the calibrated optical lobe", + false, + )); + } + let code = |u: f64| { + (f64::from(c.v_null_dac) + + 2.0 * f64::from(c.v_peak_dac - c.v_null_dac) / std::f64::consts::PI + * u.sqrt().asin()) + .round() as u16 + }; + let (low, high) = (code(low), code(high)); + if low == high || high > 4095 { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "A2 square is not representable by the stimulus DAC", + false, + )); + } + commands.push( + Command::new("MOD") + .field("wave", "SQUARE") + .field("min", low) + .field("level", high) + .field("freq_mhz", c.frequency_millihz), + ); + } + } + Ok(commands) +} + +fn a2_config_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("CONFIG") .field( - "wave", - match configuration.waveform { - stage_a_plugin_contract::PeriodicWaveformV1::Sine => "SINE", - stage_a_plugin_contract::PeriodicWaveformV1::Square => "SQUARE", + "mode", + if configuration.timing_reference == A2TimingReferenceV1::Comparator { + "A2" + } else { + "A1" }, ) - .field("freq_mhz", configuration.frequency_millihz) - .field("center_dac", configuration.center_dac) - .field("amplitude_dac", configuration.amplitude_dac) - .field("rate_hz", configuration.sample_rate_hz) + .field("rate_hz", 20_000) .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, @@ -2816,10 +3197,9 @@ fn probe_command_port(path: &str) -> Result Vec { - stage_a_io::transport::available_port_names() + stage_a_io::transport::candidate_ports() .into_iter() - // macOS lists each device twice; use the callout (cu.*) node only. - .filter(|name| name.contains("cu.usbmodem") || name.contains("ttyACM")) + .map(|port| port.name) .collect() } @@ -2829,15 +3209,11 @@ fn serial_ports() -> Vec { /// only the leading path is the value. fn port_variants() -> Vec { let mut variants = vec!["auto".to_owned(), "mock".to_owned()]; - for (name, label) in stage_a_io::transport::available_ports_with_labels() { - if !(name.contains("cu.usbmodem") || name.contains("ttyACM")) { - continue; - } - variants.push(match label { - Some(label) => format!("{name} ({label})"), - None => name, - }); - } + variants.extend( + stage_a_io::transport::candidate_ports() + .iter() + .map(stage_a_io::transport::PortInfo::variant), + ); variants } @@ -2972,16 +3348,16 @@ impl Plugin for StageAModulationPlugin { PluginServiceOutcome::Rejected { .. } => false, }; if cached_in_progress { - let terminal = self - .shared - .state - .lock() - .ok() - .and_then(|state| state.last_response.clone()) - .filter(|response| { - response.common.request_id.0 == request.request_id - && response.common.outcome != RequestOutcomeV1::InProgress - }); + let terminal = self.shared.completions.lock().ok().and_then(|responses| { + responses + .iter() + .rev() + .find(|(client, response)| { + client.as_str() == request.source_plugin_id + && response.common.request_id.0 == request.request_id + }) + .map(|(_, response)| response.clone()) + }); if let Some(terminal) = terminal { let upgraded = accepted_service_reply(request, &terminal); self.request_cache[index].1 = upgraded.clone(); @@ -3090,7 +3466,7 @@ impl Plugin for StageAModulationPlugin { key: "port".into(), label: "Port".into(), tooltip: Some( - "auto (recommended) probes the attached usbmodem ports and picks \ + "auto (recommended) probes the attached USB serial ports and picks \ the one that answers HELLO — the Teensy command port; \ mock = in-process simulated controller" .into(), @@ -3155,10 +3531,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, }, @@ -3527,7 +3907,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(); } @@ -4958,6 +5341,23 @@ mod tests { assert_eq!(state.calibration_id.as_deref(), Some("cal-test")); } + #[test] + fn applied_lobe_is_published_without_an_armed_optical_point() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::Const; + plugin.v_null_dac = 400; + plugin.v_peak_dac = 1_300; + plugin.calibration_id = Some("cal-test".into()); + + let state = plugin.control_state(); + assert!(state.optical_drive.is_none()); + let lobe = state.optical_lobe.expect("applied optical lobe"); + assert_eq!(lobe.calibration_id, "cal-test"); + assert_eq!(lobe.v_null_dac, 400); + assert_eq!(lobe.v_peak_dac, 1_300); + } + #[test] fn an_out_of_range_operating_point_is_clamped_not_rejected() { // Refusing the edit and snapping the control back is what made these @@ -5298,23 +5698,17 @@ mod tests { None, ); plugin.handle_service_request(&acquire, &live_execution()); + { + // The mode change restates what the controller reported. + let mut state = plugin.shared.state.lock().expect("device state lock"); + state.controller_rate_hz = Some(20_000); + state.controller_block_samples = Some(256); + } let prepare = service_request( &plugin, 21, "workflow-a", - ModulationCommandV1::PrepareA1 { - configuration: A1AcquisitionConfigV1 { - waveform: stage_a_plugin_contract::PeriodicWaveformV1::Sine, - frequency_millihz: 10_000, - center_dac: 1_000, - amplitude_dac: 250, - sample_rate_hz: 20_000, - block_samples: 256, - emit_raw_samples: true, - emit_summary: true, - optical_lut_id: None, - }, - }, + ModulationCommandV1::PrepareA1, Some(1), ); let initial = plugin.handle_service_request(&prepare, &live_execution()); @@ -5449,4 +5843,370 @@ mod tests { assert!(plugin.link.is_none()); assert!(plugin.lease.is_none()); } + + /// `CONFIG` answers any field it does not know with + /// `SYNTAX unknown_config_field`, and it needs a mode *and* a rate, so the + /// mode change restates the controller's own acquisition settings. + #[test] + fn a1_config_matches_the_firmware_grammar() { + // The rate `CONFIG` carries is the controller's own portable-sampler + // rate from STATUS, not the DMA stream rate the photodiode reads. + let settings = ControllerAcquisition { + rate_hz: 20_000, + block_samples: 256, + emit_raw: true, + emit_summary: true, + }; + assert_eq!( + String::from_utf8(a1_config_command(&settings).encode(1).unwrap()).unwrap(), + "@1 CONFIG mode=A1 rate_hz=20000 block_samples=256 raw=1 summary=1\n" + ); + } + + #[test] + fn a2_commands_match_the_firmware_grammar_and_use_lobe_span() { + let config = A2AcquisitionConfigV1 { + timing_reference: A2TimingReferenceV1::Comparator, + 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=20000 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()); + } + #[test] + fn a2_accepts_the_actual_firmware_trigger_names_and_rejects_wrong_modes() { + let mut fields = BTreeMap::from([ + ("trigger_source".into(), "PD_COMPARATOR".into()), + ("cmp_armed".into(), "1".into()), + ("mod_wave".into(), "LOG_SQUARE".into()), + ]); + assert!(verify_a2_prepared(&fields, A2TimingReferenceV1::Comparator).is_ok()); + assert!(verify_a2_prepared(&fields, A2TimingReferenceV1::DriveSync).is_err()); + fields.insert("trigger_source".into(), "J24_PHASE0".into()); + fields.insert("cmp_armed".into(), "0".into()); + fields.insert("mod_wave".into(), "SQUARE".into()); + assert!(verify_a2_prepared(&fields, A2TimingReferenceV1::DriveSync).is_ok()); + assert!(verify_a2_prepared(&fields, A2TimingReferenceV1::Comparator).is_err()); + } + + #[test] + fn drive_sync_a2_prepares_with_real_owner_and_mock_transport_without_start() { + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + plugin.connect_requested = true; + plugin.connect(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + let acquire = service_request( + &plugin, + 80, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + let prepare = service_request( + &plugin, + 81, + "workflow-a", + ModulationCommandV1::PrepareA2 { + configuration: A2AcquisitionConfigV1 { + timing_reference: A2TimingReferenceV1::DriveSync, + mean_u_milli: 300, + depth_a_milli: 450, + frequency_millihz: 500, + min_half_us: 0, + v_null_dac: 100, + v_peak_dac: 1000, + comparator_threshold_dac: 0, + comparator_hysteresis: 1, + comparator_invert: false, + sample_rate_hz: 500_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + }, + }, + Some(1), + ); + plugin.handle_service_request(&prepare, &live_execution()); + wait_until(&plugin, Duration::from_secs(2), |p| { + p.shared + .state + .lock() + .unwrap() + .last_response + .as_ref() + .is_some_and(|r| { + r.common.request_id.0 == 81 && r.common.outcome != RequestOutcomeV1::InProgress + }) + }); + let state = plugin.shared.state.lock().unwrap(); + let response = state.last_response.as_ref().unwrap(); + assert_eq!( + response.common.outcome, + RequestOutcomeV1::Applied, + "{response:?}" + ); + assert!(response.marker_diagnostics.unwrap().dma_sample_clock); + assert_eq!(state.controller_state, ControllerStateV1::Configured); + assert_eq!(state.board_wave.as_deref(), Some("SQUARE")); + } + /// A2's drive-synchronized capture configures A1 mode at its own sample + /// rate, and the firmware keeps an armed comparator until a `CONFIG` + /// leaves A2 — so "the controller already says A1" proves nothing about + /// what the acquisition runs with. Preparation always restates it. + #[test] + fn a1_preparation_always_restates_the_configuration() { + let settings = ControllerAcquisition { + rate_hz: 500_000, + block_samples: 2_048, + emit_raw: true, + emit_summary: true, + }; + let rendered: Vec = a1_prepare_commands(&settings) + .iter() + .map(|command| String::from_utf8(command.encode(1).expect("encodes")).unwrap()) + .collect(); + assert_eq!( + rendered, + vec![ + "@1 STOP reason=prepare_a1\n".to_string(), + "@1 CONFIG mode=A1 rate_hz=500000 block_samples=2048 raw=1 summary=1\n".into(), + "@1 START\n".into(), + "@1 STATUS\n".into(), + ] + ); + } + + #[test] + fn queued_mode_and_drive_commands_all_reach_the_controller_and_remain_queryable() { + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + plugin.connect_requested = true; + plugin.connect(); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .controller_rate_hz + .is_some() + }); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.calibration_id = Some("test-lobe".into()); + let lease = service_request( + &plugin, + 200, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&lease, &live_execution()); + let requests = [ + service_request( + &plugin, + 201, + "workflow-a", + ModulationCommandV1::PrepareA1, + Some(1), + ), + service_request( + &plugin, + 202, + "workflow-a", + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: 5_000, + }, + None, + ), + service_request( + &plugin, + 203, + "workflow-a", + ModulationCommandV1::SetOpticalDepth { depth_a_milli: 500 }, + None, + ), + ]; + for request in &requests { + let reply = plugin.handle_service_request(request, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = reply.outcome else { + panic!("queue rejected"); + }; + let response: ModulationResponseV1 = serde_json::from_value(payload).unwrap(); + assert_eq!(response.common.outcome, RequestOutcomeV1::InProgress); + } + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner.shared.completions.lock().unwrap().len() == 3 + }); + for request in &requests { + let reply = plugin.handle_service_request(request, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = reply.outcome else { + panic!("completion rejected"); + }; + let response: ModulationResponseV1 = serde_json::from_value(payload).unwrap(); + assert_eq!( + response.common.outcome, + RequestOutcomeV1::Applied, + "{:?}", + response.common.error + ); + } + let state = plugin.control_state(); + assert_eq!(state.controller_mode.as_deref(), Some("A1")); + assert_eq!(state.controller_state, ControllerStateV1::Running); + let target = state.acknowledged.unwrap(); + assert!(matches!( + target.waveform, + Some(WaveformV1::Periodic { + frequency_millihz: 5_000, + .. + }) + )); + plugin.disconnect(); + } + + #[test] + fn firmware_rejection_is_retained_after_a_later_success() { + let (mock, mut client) = MockService::spawn(); + let shared = SharedLink::new(); + for (id, command) in [ + (1, Command::new("NOT_A_COMMAND")), + (2, Command::new("STATUS")), + ] { + let operation = PendingOperation { + commands: vec![command], + purpose: "TEST", + meta: Some(OperationMeta { + request_id: stage_a_plugin_contract::RequestId(id), + requester: ClientId::new("workflow"), + run_id: None, + requested_revision: SemanticRevision(id), + owner_instance: OwnerInstanceId::new("test"), + target: ModulationTargetV1 { + revision: SemanticRevision(id), + waveform: None, + a2_configuration: None, + acquisition_running: false, + board_dac_code: None, + firmware_configuration_revision: None, + }, + }), + }; + assert_eq!(execute_operation(&mut client, &shared, operation), id == 2); + } + let completions = shared.completions.lock().unwrap(); + assert_eq!(completions[0].1.common.outcome, RequestOutcomeV1::Rejected); + assert_eq!(completions[1].1.common.outcome, RequestOutcomeV1::Applied); + drop(mock); + } + #[test] + fn repeated_a2_drive_sync_to_a1_switches_restart_acquisition() { + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + plugin.connect_requested = true; + plugin.connect(); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .controller_rate_hz + .is_some() + }); + let lease = service_request( + &plugin, + 300, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&lease, &live_execution()); + for cycle in 0..5 { + let configuration = A2AcquisitionConfigV1 { + timing_reference: A2TimingReferenceV1::DriveSync, + 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: 0, + comparator_hysteresis: 1, + comparator_invert: false, + sample_rate_hz: 500_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + }; + for (offset, command) in [ + (0, ModulationCommandV1::PrepareA2 { configuration }), + (1, ModulationCommandV1::PrepareA1), + ] { + let revision = 1 + cycle * 2 + offset; + let request = service_request( + &plugin, + 300 + revision, + "workflow-a", + command, + Some(revision), + ); + plugin.handle_service_request(&request, &live_execution()); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .completions + .lock() + .unwrap() + .iter() + .any(|(_, r)| r.common.request_id.0 == 300 + revision) + }); + let PluginServiceOutcome::Accepted { payload } = plugin + .handle_service_request(&request, &live_execution()) + .outcome + else { + panic!("command rejected"); + }; + let response: ModulationResponseV1 = serde_json::from_value(payload).unwrap(); + assert_eq!( + response.common.outcome, + RequestOutcomeV1::Applied, + "{:?}", + response.common.error + ); + } + let state = plugin.control_state(); + assert_eq!(state.controller_state, ControllerStateV1::Running); + assert!(state.acknowledged.unwrap().a2_configuration.is_none()); + } + plugin.disconnect(); + } } 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..ae1a1e5 --- /dev/null +++ b/plugins/stage-a-modulation/src/protocol_validation_tests.rs @@ -0,0 +1,386 @@ +//! 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 serde_json::Value; +use stage_a_plugin_contract::protocol::{parse_csv, ProtocolPoint}; +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; 8] { + [ + Fixture { + name: "a1_lux_dark_offset.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_lux_dark_offset.csv"), + expected_points: 1, + }, + Fixture { + name: "a1_illuminated_smoke.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_illuminated_smoke.csv"), + expected_points: 1, + }, + Fixture { + name: "a1_fc_flux_discriminator.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_fc_flux_discriminator.csv"), + expected_points: 297, + }, + 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 started = std::time::Instant::now(); + let reply = loop { + let reply = plugin.handle_service_request(&request, &live_execution()); + if let PluginServiceOutcome::Accepted { payload } = &reply.outcome { + let response: stage_a_plugin_contract::ModulationResponseV1 = + serde_json::from_value(payload.clone()).unwrap(); + if response.common.outcome == stage_a_plugin_contract::RequestOutcomeV1::InProgress { + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "{context}: no device completion" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + assert_eq!( + response.common.outcome, + stage_a_plugin_contract::RequestOutcomeV1::Applied, + "{context}: {:?}", + response.common.error + ); + } + break reply; + }; + 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() + ); + } + } +} diff --git a/plugins/stage-a-photodiode/Cargo.toml b/plugins/stage-a-photodiode/Cargo.toml index 3e0c8c0..b83a483 100644 --- a/plugins/stage-a-photodiode/Cargo.toml +++ b/plugins/stage-a-photodiode/Cargo.toml @@ -13,5 +13,8 @@ crate-type = ["cdylib", "rlib"] augur-plugin-api.workspace = true serde_json.workspace = true serialport.workspace = true -stage-a-io = { path = "../../stage-a-io", default-features = false } +# `hardware` brings in the shared platform-aware port discovery; serialport is +# 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" } + diff --git a/plugins/stage-a-photodiode/README.md b/plugins/stage-a-photodiode/README.md index 2406154..9cbd62d 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,17 +33,51 @@ 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; - a compact status table with the newest code/value, moving average, integrity, recording state, and connection state. +## Guided reference recordings + +Open **Guided PD references** for raw captures that describe the detector noise +and the current analogue chain. Choose one reference-set ID and keep it for the +whole bench configuration. The plugin creates one folder with clearly named +PDQ and JSON files. It never overwrites an existing reference. The JSON records +the detector placement, splitter fraction, duration, reference type and PD load +(470 kOhm for the current setup). + +The four steps are: + +1. **Electronics dark, 30 s:** block all light before the PD and keep modulation + safely off. This measures the ADC, cable and amplified PD background. +2. **Blocked drive crosstalk, 30 s:** keep the light blocked and run the normal + experiment modulation. This separates electrical pickup from optical light. +3. **Static optical signal, 30 s:** open the path, use a constant drive and do + not modulate. This measures noise at the real DC level. +4. **Optical edges, 100 s:** use the A2 workflow for automatic 1 s steps. The + standalone PD button can record an already running sequence, but it does not + take hardware ownership from A1 or A2. + +Each standalone capture stops automatically and advances the panel to the next +step. Advanced noise or trigger-error +analysis is intentionally offline; the raw samples and markers are the source +of truth. Modulation remains in A1/A2 because those workflows own the command +port and can restore the hardware safely. Comparator threshold, hysteresis and +polarity are therefore not duplicated in this general PD panel. + ## Ports -**Use `auto` (default recommendation):** it listens briefly on every attached usbmodem/ttyACM -device and connects to the one actually streaming CRC-clean PDA1 sample frames — that is always -the Teensy stream port. `mock` generates a synthetic sine for hardware-free testing. +**Use `auto` (default recommendation):** it listens briefly on every attached USB serial port and +connects to the one actually streaming CRC-clean PDA1 sample frames — that is always the Teensy +stream port. `mock` generates a synthetic sine for hardware-free testing. + +Which ports get listened to is platform-specific: `cu.usbmodem*` on macOS, `ttyACM*` on Linux, +and every USB-classified `COMn` on Windows (ADR 032). ## Owner control service diff --git a/plugins/stage-a-photodiode/src/lib.rs b/plugins/stage-a-photodiode/src/lib.rs index 4d4cf3e..c2c294d 100644 --- a/plugins/stage-a-photodiode/src/lib.rs +++ b/plugins/stage-a-photodiode/src/lib.rs @@ -21,11 +21,15 @@ //! 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}; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -47,7 +51,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 +129,105 @@ enum Mode { Excitation, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReferenceKind { + ElectronicsDark, + DriveCrosstalk, + StaticOptical, + OpticalEdges, +} + +impl ReferenceKind { + const VARIANTS: [Self; 4] = [ + Self::ElectronicsDark, + Self::DriveCrosstalk, + Self::StaticOptical, + Self::OpticalEdges, + ]; + + fn name(self) -> &'static str { + match self { + Self::ElectronicsDark => "1 - electronics dark", + Self::DriveCrosstalk => "2 - blocked drive crosstalk", + Self::StaticOptical => "3 - static optical signal", + Self::OpticalEdges => "4 - optical edges with comparator", + } + } + + fn slug(self) -> &'static str { + match self { + Self::ElectronicsDark => "electronics_dark", + Self::DriveCrosstalk => "blocked_drive_crosstalk", + Self::StaticOptical => "static_optical", + Self::OpticalEdges => "optical_edges", + } + } + + fn duration_s(self) -> u64 { + match self { + Self::ElectronicsDark | Self::DriveCrosstalk | Self::StaticOptical => 30, + Self::OpticalEdges => 100, + } + } + + fn next(self) -> Self { + match self { + Self::ElectronicsDark => Self::DriveCrosstalk, + Self::DriveCrosstalk => Self::StaticOptical, + Self::StaticOptical => Self::OpticalEdges, + Self::OpticalEdges => Self::OpticalEdges, + } + } + + fn instruction(self) -> &'static str { + match self { + Self::ElectronicsDark => { + "Block all light before the photodiode. The modulation must be safely off." + } + Self::DriveCrosstalk => { + "Keep the light blocked and run the normal A1/A2 modulation. This measures electrical pickup." + } + Self::StaticOptical => { + "Open the optical path and keep the drive constant. Do not modulate during this capture." + } + Self::OpticalEdges => { + "Use the A2 workflow for automatic 1 s optical steps and comparator markers. Standalone mode only records an already running sequence." + } + } + } + + fn from_name(name: &str) -> Option { + Self::VARIANTS.into_iter().find(|kind| kind.name() == name) + } +} + +#[derive(Debug, Clone)] +struct ActiveReferenceCapture { + kind: ReferenceKind, + set_id: String, + deadline_unix_ms: u64, +} + +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 +289,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. /// @@ -195,6 +303,15 @@ struct SharedState { /// past instead of trying to recover it from what survived eviction. last_marker_index: Option, marker_period_estimate: Option, + /// Phase-0 markers stamped before the retained window since the current + /// segment began. A trigger that never lands inside the ring is not a + /// trigger that is missing: it is one whose sample index does not belong + /// to this stream, and the two need different words. + markers_outside_window: u64, + /// When the ring last restarted, host clock. A restart clears the samples + /// and their markers together, so a window that is short because of one is + /// not a window that needs a longer cache. + last_segment_restart_unix_ms: Option, latest: Option, /// Cumulative firmware-side drop counter (latest header value). device_dropped: u32, @@ -256,8 +373,11 @@ 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, + markers_outside_window: 0, + last_segment_restart_unix_ms: None, latest: None, device_dropped: 0, crc_failures: 0, @@ -272,8 +392,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, @@ -289,14 +445,17 @@ impl SharedState { if !continuous { if !self.samples.is_empty() { self.segments += 1; + self.last_segment_restart_unix_ms = Some(now_unix_ms()); } 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; self.marker_period_estimate = None; + self.markers_outside_window = 0; self.ring_first_index = first_index; self.rate_hz = rate_hz; } @@ -350,12 +509,20 @@ 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 /// current ring window. Bounded so a marker storm cannot grow unbounded. fn push_marker(&mut self, sample_index: u64) { if sample_index < self.ring_first_index { + self.markers_outside_window += 1; return; } if self @@ -378,6 +545,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. /// @@ -412,6 +597,34 @@ impl SharedState { (period > 0.0).then_some(period) } + /// The refusal for a window that bounds no two whole modulation cycles, + /// told apart by *why* it does not. + /// + /// The three benches need three different actions, and only one of them is + /// the cache length: a stream that keeps restarting drops samples, a + /// controller outside `mode=A1` stamps no phase-0 marker at all, and a + /// marker clock that disagrees with the sample clock stamps markers the + /// ring can never hold. Naming the window length for all three sent the + /// operator to raise a cache that was never the gate. + fn incomplete_cycles(&self, marker_count: usize) -> EstimateError { + let rate = f64::from(self.rate_hz.max(1)); + let retained_seconds = self.samples.len() as f64 / rate; + // Only a restart the ring has not had time to refill after explains a + // short window; an older one is history the operator cannot act on. + let capacity_seconds = self.ring_capacity(self.rate_hz) as f64 / rate; + let now = now_unix_ms(); + let restarted_seconds_ago = self + .last_segment_restart_unix_ms + .map(|at| now.saturating_sub(at) as f64 / 1_000.0) + .filter(|elapsed| *elapsed < capacity_seconds); + EstimateError::IncompleteModulationCycles { + marker_count, + retained_seconds, + restarted_seconds_ago, + markers_outside_window: self.markers_outside_window, + } + } + /// min/max/sum over deque offsets `[start, end)`, combining whole /// summary cells with raw samples at the edges: O(range/64 + 128) /// instead of O(range). @@ -462,8 +675,9 @@ impl SharedState { /// One active disk recording: every clean `SamplesU16` frame is appended /// verbatim to a `.pdq` file; `stop` writes the JSON sidecar next to it. +/// The file itself is written by [`RecordingWriter`] on its own thread. struct RecordingSink { - writer: PdqWriter, + writer: RecordingWriter, pdq_path: PathBuf, sidecar_path: PathBuf, pdq_path_label: String, @@ -475,6 +689,7 @@ struct RecordingSink { metadata: BTreeMap, started_slug: String, samples_written: u64, + marker_counts: stage_a_plugin_contract::PdqMarkerCountsV1, write_error: Option, /// Integrity counters at recording start, so the sidecar reports deltas /// for exactly the recorded span. @@ -499,6 +714,79 @@ impl RecordingSink { type SharedRecording = Arc>>; +/// Depth of the hand-off queue between the stream reader and the disk writer, +/// in wire frames. One frame is 2048 samples — about 4 ms at 500 kSa/s — so +/// this absorbs roughly eight seconds of storage latency at 4 MB of memory. +const WRITER_QUEUE_FRAMES: usize = 1_024; + +/// The `.pdq` writer, running on its own thread. +/// +/// The reader thread must never wait for the file system. It owns the serial +/// port, and the firmware keeps only two DMA blocks (~8 ms at 500 kSa/s): +/// every millisecond the reader spends inside a write is a millisecond in +/// which the device can overrun and discard whole blocks. A recording written +/// to a network share stalled the reader for 50-280 ms at a time, which broke +/// the `.pdq` into several sample segments and cost A1/A2 the point. Frames +/// are therefore handed over through a bounded queue and written here. +struct RecordingWriter { + /// `None` once the queue is closed, which ends the writer thread. + frames: Option>, + join: Option)>>, +} + +impl RecordingWriter { + fn spawn(mut writer: PdqWriter) -> Self { + let (frames, receiver) = sync_channel::(WRITER_QUEUE_FRAMES); + let join = std::thread::Builder::new() + .name("stage-a-pdq-writer".into()) + .spawn(move || { + let mut error: Option = None; + for frame in receiver { + // After a failure the queue is still drained: the reader + // must not start blocking because this thread stopped. + if error.is_some() { + continue; + } + if let Err(err) = writer.write_frame(&frame) { + error = Some(format!("recording write failed: {err}")); + } + } + (writer, error) + }) + .expect("spawning the photodiode recording writer thread must succeed"); + Self { + frames: Some(frames), + join: Some(join), + } + } + + /// Hands one frame to the writer thread without ever blocking. `false` + /// means the queue is full — storage is seconds behind the stream, and + /// the recording cannot be completed. + fn enqueue(&self, frame: &stage_a_io::Frame) -> bool { + self.frames + .as_ref() + .is_some_and(|frames| frames.try_send(frame.clone()).is_ok()) + } + + /// Closes the queue, waits for the queued frames to reach the file and + /// finalizes it. Returns the summary together with the first write error + /// the writer thread saw, if any. + fn finish( + mut self, + integrity: StreamIntegrity, + ) -> std::io::Result<(stage_a_io::PdqSummary, Option)> { + self.frames = None; // Closing the queue ends the writer loop. + let Some(join) = self.join.take() else { + return Err(std::io::Error::other("recording writer thread is gone")); + }; + let (writer, error) = join + .join() + .map_err(|_| std::io::Error::other("the recording writer thread panicked"))?; + writer.finish(integrity).map(|summary| (summary, error)) + } +} + fn record_frame(recording: &SharedRecording, frame: &stage_a_io::Frame, samples: usize) { let Ok(mut slot) = recording.lock() else { return; @@ -509,9 +797,24 @@ fn record_frame(recording: &SharedRecording, frame: &stage_a_io::Frame, samples: if sink.write_error.is_some() { return; } - match sink.writer.write_frame(frame) { - Ok(()) => sink.samples_written += samples as u64, - Err(err) => sink.write_error = Some(format!("recording write failed: {err}")), + if !sink.writer.enqueue(frame) { + sink.write_error = Some(format!( + "recording queue overflow after {} samples: the storage target cannot keep up \ + with the stream", + sink.samples_written + )); + return; + } + sink.samples_written += samples as u64; + if let Some(marker) = frame.marker() { + let counts = &mut sink.marker_counts; + match (marker.source, marker.level) { + (stage_a_io::wire::MARKER_SOURCE_PHASE0, 1) => counts.phase_zero += 1, + (stage_a_io::wire::MARKER_SOURCE_COMPARATOR, 1) => counts.comparator_rising += 1, + (stage_a_io::wire::MARKER_SOURCE_COMPARATOR, 0) => counts.comparator_falling += 1, + (_, 2..=u8::MAX) => counts.invalid_level += 1, + _ => {} + } } } @@ -556,6 +859,8 @@ impl Reader { ) -> Result { let port = serialport::new(&path, 115_200) .timeout(Duration::from_millis(50)) + // Windows opens with DTR deasserted; see ADR 032. + .dtr_on_open(true) .open() .map_err(|err| format!("open {path}: {err}"))?; let stop = Arc::new(AtomicBool::new(false)); @@ -725,7 +1030,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; } @@ -778,17 +1091,40 @@ 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, time_axis: TimeAxis, /// Overlay the phase-0 trigger markers on the chart (opt-in). show_markers: bool, + /// Overlay A2 comparator state changes independently of phase-0 markers. + show_comparator_markers: bool, data_dir: String, + reference_set_id: String, + reference_kind: ReferenceKind, + load_ohms: f64, + active_reference: Option, // -- 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, + press_reference_start: PressLatch, + press_reference_abort: PressLatch, } /// Forwards momentary button presses across the host's UI-mirror → live-worker @@ -844,6 +1180,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 { @@ -871,15 +1227,28 @@ 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, time_axis: TimeAxis::BeforeNow, show_markers: false, + show_comparator_markers: false, data_dir: String::new(), + reference_set_id: String::new(), + reference_kind: ReferenceKind::ElectronicsDark, + load_ohms: 470_000.0, + active_reference: None, 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(), + press_reference_start: PressLatch::default(), + press_reference_abort: PressLatch::default(), } } } @@ -907,6 +1276,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 { @@ -1167,6 +1548,80 @@ impl StageAPhotodiodePlugin { Ok(()) } + fn start_reference_capture(&mut self) -> Result<(), String> { + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + return Err("reference capture is allowed only on the active live worker".into()); + } + if self.lease.is_some() { + return Err("the A1/A2 workflow currently owns photodiode recording".into()); + } + if !self.connected() { + return Err("connect the photodiode stream first".into()); + } + let kind = self.reference_kind; + let set_id = if self.reference_set_id.trim().is_empty() { + format!("PDREF-{}", timestamp_slug()) + } else { + self.reference_set_id.trim().to_owned() + }; + if !set_id + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err("reference set ID may contain only letters, numbers, '-' and '_'".into()); + } + let root = self.resolved_data_dir()?.join(&set_id); + std::fs::create_dir_all(&root) + .map_err(|error| format!("creating {} failed: {error}", root.display()))?; + let stem = format!("{}__{}", set_id, kind.slug()); + let pdq_path = root.join(format!("{stem}.pdq")); + let sidecar_path = root.join(format!("{stem}.json")); + let mut metadata = BTreeMap::new(); + metadata.insert("recording_kind".into(), "photodiode_reference".into()); + metadata.insert("reference_set_id".into(), set_id.clone()); + metadata.insert("reference_kind".into(), kind.slug().into()); + metadata.insert("planned_duration_s".into(), kind.duration_s().to_string()); + metadata.insert("load_ohms".into(), format!("{:.0}", self.load_ohms)); + metadata.insert("operator_instruction".into(), kind.instruction().into()); + self.open_recording( + RunId::new(format!("{set_id}-{}", kind.slug())), + pdq_path.clone(), + sidecar_path.clone(), + pdq_path.to_string_lossy().into_owned(), + sidecar_path.to_string_lossy().into_owned(), + metadata, + true, + )?; + self.reference_set_id = set_id.clone(); + self.active_reference = Some(ActiveReferenceCapture { + kind, + set_id, + deadline_unix_ms: now_unix_ms() + kind.duration_s() * 1_000, + }); + self.last_save_note = Some(format!( + "reference {} recording for {} s", + kind.slug(), + kind.duration_s() + )); + Ok(()) + } + + fn finish_reference_capture(&mut self, termination: PdqTerminationV1) -> Result<(), String> { + let active = self.active_reference.take(); + self.finalize_recording(termination)?; + if let Some(active) = active { + self.last_save_note = Some(format!( + "reference saved: {}/{}", + active.set_id, + active.kind.slug() + )); + if termination == PdqTerminationV1::Completed { + self.reference_kind = active.kind.next(); + } + } + Ok(()) + } + #[allow(clippy::too_many_arguments)] fn open_recording( &mut self, @@ -1224,7 +1679,7 @@ impl StageAPhotodiodePlugin { } } let sink = RecordingSink { - writer, + writer: RecordingWriter::spawn(writer), pdq_path: pdq_path.clone(), sidecar_path, pdq_path_label, @@ -1236,6 +1691,7 @@ impl StageAPhotodiodePlugin { metadata, started_slug, samples_written: 0, + marker_counts: stage_a_plugin_contract::PdqMarkerCountsV1::default(), write_error: None, start_crc_failures: crc, start_resync_bytes: resync, @@ -1251,8 +1707,12 @@ impl StageAPhotodiodePlugin { } fn stop_recording(&mut self) -> Result<(), String> { - self.finalize_recording(PdqTerminationV1::OperatorStopped) - .map(|_| ()) + if self.active_reference.is_some() { + self.finish_reference_capture(PdqTerminationV1::OperatorStopped) + } else { + self.finalize_recording(PdqTerminationV1::OperatorStopped) + .map(|_| ()) + } } fn finalize_recording( @@ -1278,7 +1738,7 @@ impl StageAPhotodiodePlugin { sequence_gaps: segments.saturating_sub(sink.start_segments), dropped_samples: u64::from(dropped.saturating_sub(sink.start_device_dropped)), }; - let write_error = sink.write_error.clone(); + let enqueue_error = sink.write_error.clone(); let started = sink.started_slug.clone(); let samples = sink.samples_written; let pdq_path = sink.pdq_path.clone(); @@ -1288,10 +1748,11 @@ impl StageAPhotodiodePlugin { let pdq_path_label = sink.pdq_path_label.clone(); let sidecar_path_label = sink.sidecar_path_label.clone(); let metadata = sink.metadata.clone(); - let summary = sink + let (summary, writer_error) = sink .writer .finish(integrity) .map_err(|err| format!("finishing recording failed: {err}"))?; + let write_error = enqueue_error.or(writer_error); let contract_integrity = contract_integrity(summary.integrity, summary.sample_segments); let receipt = PdqFinalizedReceiptV1 { run_id: run_id.clone(), @@ -1304,6 +1765,7 @@ impl StageAPhotodiodePlugin { .map_err(|err| format!("invalid recording digest: {err}"))?, frames_written: summary.frames_written, sample_frames_written: summary.sample_frames_written, + marker_counts: Some(sink.marker_counts), sample_range: summary.sample_range.map(|range| SampleRangeV1 { first_sample_index: range.first_sample_index, end_sample_index_exclusive: range.end_sample_index_exclusive, @@ -1313,7 +1775,7 @@ impl StageAPhotodiodePlugin { segment_count: summary.sample_segments, integrity: contract_integrity, termination, - valid: summary.valid && write_error.is_none(), + valid: summary.valid && write_error.is_none() && samples > 0, }; let sidecar = json!({ "kind": "recording", @@ -1325,6 +1787,7 @@ impl StageAPhotodiodePlugin { "samples_written": samples, "pdq_path": summary.path, "pdq_frames": summary.frames_written, + "marker_counts": receipt.marker_counts, "pdq_bytes": summary.bytes_written, "pdq_crc32": summary.file_crc32, "pdq_sha256": receipt.sha256.as_str(), @@ -1332,15 +1795,27 @@ 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), + "reference_set_id": Some(self.reference_set_id.trim()) + .filter(|value| !value.is_empty()), + "load_ohms": self.load_ohms, + "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, "segment_restarts": summary.integrity.sequence_gaps, "device_dropped_samples": summary.integrity.dropped_samples, }, - "valid": summary.valid && write_error.is_none(), + "valid": summary.valid && write_error.is_none() && samples > 0, "write_error": write_error, }); write_json(&sidecar_path, &sidecar)?; @@ -1597,13 +2072,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 @@ -1618,9 +2091,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 @@ -1630,10 +2115,7 @@ impl StageAPhotodiodePlugin { .filter(|index| *index >= state.ring_first_index && *index <= ring_end) .collect(); if markers.len() < 3 { - return Err(EstimateError::IncompleteModulationCycles { - marker_count: markers.len(), - max_samples: state.samples.len(), - }); + return Err(state.incomplete_cycles(markers.len())); } // End on the newest complete phase-0 boundary. Start at least two @@ -1650,12 +2132,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 @@ -1668,13 +2154,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, @@ -1829,6 +2325,15 @@ 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), + reference_set_id: Some(self.reference_set_id.trim().to_owned()) + .filter(|value| !value.is_empty()), + load_ohms: Some(self.load_ohms), + dark_reference: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()) + .flatten(), optical_unavailable, synchronization, last_response: self.last_response.clone(), @@ -1868,6 +2373,7 @@ impl StageAPhotodiodePlugin { if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { self.last_error = Some(error); } + self.active_reference = None; // Deliberately keep `connect_requested`: it is the operator's // *intent*, and this branch is what the UI mirror runs on every // control tick. Clearing it there resets the checkbox before the @@ -1958,8 +2464,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, }); @@ -1979,9 +2494,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), } } @@ -2106,7 +2627,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 }); @@ -2215,6 +2738,42 @@ impl StageAPhotodiodePlugin { } } } + if self.show_comparator_markers && !state.comparator_markers.is_empty() { + let first_visible = state.ring_first_index + start as u64; + let y_range = lines + .iter() + .flat_map(|line| line.points.iter()) + .map(|point| point.y) + .fold(None::<(f64, f64)>, |acc, y| { + Some(acc.map_or((y, y), |(lo, hi)| (lo.min(y), hi.max(y)))) + }); + if let Some((y_lo, y_hi)) = y_range { + let x_for = |index: u64| -> f64 { + let device_t = index as f64 / rate; + match self.time_axis { + TimeAxis::BeforeNow => device_t - latest_x_index as f64 / rate, + TimeAxis::Segment => device_t, + } + }; + let mut points = Vec::with_capacity(state.comparator_markers.len() * 3); + for &(index, level) in &state.comparator_markers { + if index < first_visible || index > latest_x_index { + continue; + } + let x = x_for(index); + let marker_top = if level == 0 { y_lo } else { y_hi }; + points.push(Series1dPoint { x, y: y_lo }); + points.push(Series1dPoint { x, y: marker_top }); + points.push(Series1dPoint { x, y: y_lo }); + } + if !points.is_empty() { + lines.push(Series1dLine { + name: "comparator state".into(), + points, + }); + } + } + } Series1dV1 { x_label: x_label.into(), y_label: y_label.into(), @@ -2316,7 +2875,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), }; @@ -2527,16 +3088,10 @@ fn rejected_service_reply( } fn serial_ports() -> Vec { - serialport::available_ports() - .map(|ports| { - ports - .into_iter() - .map(|p| p.port_name) - // macOS lists each device twice; use the callout (cu.*) node only. - .filter(|name| name.contains("cu.usbmodem") || name.contains("ttyACM")) - .collect() - }) - .unwrap_or_default() + stage_a_io::transport::candidate_ports() + .into_iter() + .map(|port| port.name) + .collect() } /// Finds the Teensy stream port: the dual-serial firmware free-runs PDA1 @@ -2545,7 +3100,7 @@ fn serial_ports() -> Vec { fn resolve_auto_port() -> Result { let candidates = serial_ports(); if candidates.is_empty() { - return Err("no USB serial device found (looked for usbmodem/ttyACM)".to_owned()); + return Err(stage_a_io::transport::no_candidate_ports_message()); } let mut saw_legacy_ascii = false; for path in &candidates { @@ -2589,6 +3144,8 @@ enum ProbeResult { fn probe_pd_stream(path: &str) -> ProbeResult { let Ok(mut port) = serialport::new(path, 115_200) .timeout(Duration::from_millis(100)) + // Windows opens with DTR deasserted; see ADR 032. + .dtr_on_open(true) .open() else { return ProbeResult::Nothing; @@ -2632,26 +3189,11 @@ fn probe_pd_stream(path: &str) -> ProbeResult { /// host exchanges enum settings as indices into this list. fn port_variants() -> Vec { let mut variants = vec!["mock".to_owned(), "auto".to_owned()]; - for port in serialport::available_ports().unwrap_or_default() { - if !(port.port_name.contains("cu.usbmodem") || port.port_name.contains("ttyACM")) { - continue; - } - let label = match port.port_type { - serialport::SerialPortType::UsbPort(info) => match (info.manufacturer, info.product) { - (Some(manufacturer), Some(product)) if !product.starts_with(&manufacturer) => { - Some(format!("{manufacturer} {product}")) - } - (_, Some(product)) => Some(product), - (Some(manufacturer), None) => Some(manufacturer), - (None, None) => None, - }, - _ => None, - }; - variants.push(match label { - Some(label) => format!("{} ({label})", port.port_name), - None => port.port_name, - }); - } + variants.extend( + stage_a_io::transport::candidate_ports() + .iter() + .map(stage_a_io::transport::PortInfo::variant), + ); variants } @@ -2681,7 +3223,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 { @@ -2702,6 +3244,7 @@ impl Plugin for StageAPhotodiodePlugin { if let Err(err) = self.finalize_recording(termination) { self.last_error = Some(err); } + self.active_reference = None; self.disconnect(); self.lease = None; } @@ -2713,6 +3256,7 @@ impl Plugin for StageAPhotodiodePlugin { if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { self.last_error = Some(error); } + self.active_reference = None; // Demoting to the UI mirror drops the hardware, not the operator's // connect intent — see `apply_execution_context`. self.disconnect(); @@ -2739,6 +3283,7 @@ impl Plugin for StageAPhotodiodePlugin { if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { self.last_error = Some(error); } + self.active_reference = None; // Runs every replayed frame, so it must not clear the intent // either — the port stays closed because `connect()` is guarded. self.disconnect(); @@ -2749,6 +3294,15 @@ impl Plugin for StageAPhotodiodePlugin { fn process_control(&mut self, context: &mut PluginControlContext<'_>) { let execution = context.execution(); self.apply_execution_context(&execution); + if self + .active_reference + .as_ref() + .is_some_and(|capture| now_unix_ms() >= capture.deadline_unix_ms) + { + if let Err(error) = self.finish_reference_capture(PdqTerminationV1::Completed) { + self.last_error = Some(error); + } + } } fn handle_service_request( @@ -2857,6 +3411,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 { @@ -2864,12 +3426,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, @@ -2899,6 +3459,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(), @@ -2991,6 +3620,93 @@ impl Plugin for StageAPhotodiodePlugin { default: self.show_markers, }, }, + SettingItem { + key: "show_comparator_markers".into(), + label: "Show comparator markers".into(), + tooltip: Some( + "Overlay A2 comparator state changes from marker source 2. This is separate from phase-0 timing markers." + .into(), + ), + kind: SettingKind::Bool { + default: self.show_comparator_markers, + }, + }, + ], + }, + SettingsSection { + label: "Guided PD references".into(), + description: Some(format!( + "Step: {}\nDuration: {} s\n\n{}\n\nThe capture stops automatically and is saved as a raw PDQ plus JSON. A1/A2 owns automatic modulation; this panel does not change the drive behind an active workflow.", + self.reference_kind.name(), + self.reference_kind.duration_s(), + self.reference_kind.instruction() + )), + default_open: false, + items: vec![ + SettingItem { + key: "reference_set_id".into(), + label: "Reference set ID".into(), + tooltip: Some( + "Use the same ID in the A2 protocol. Leave empty to generate PDREF-